diff --git a/.env.example b/.env.example index e8a5ec1..d130123 100644 --- a/.env.example +++ b/.env.example @@ -45,4 +45,15 @@ COPYRIGHTER_GOOGLE_CUSTOM_SEARCH_DAILY_LIMIT=100 COPYRIGHTER_LLM_DAILY_LIMIT=100 OLLAMA_BASE_URL=http://localhost:11434 -OLLAMA_MODEL=qwen2.5:0.5b-instruct +# 3B is the floor for reading evidence and citing it. 0.5B ignored the output +# contract (answered in English, reprinted the evidence list). Pull it on the +# build machine: `ollama pull qwen2.5:3b-instruct`. +OLLAMA_MODEL=qwen2.5:3b-instruct +# Context window. Ollama defaults to a couple of thousand tokens and silently +# truncates past it — the summary would then cite evidence the model never read. +OLLAMA_NUM_CTX=8192 +OLLAMA_NUM_PREDICT=512 +# Seconds. Structured output on a 3B model needs more headroom than the old 30s. +OLLAMA_TIMEOUT=120 +# Keep weights resident between cases so a batch does not reload per case. +OLLAMA_KEEP_ALIVE=10m diff --git a/src/rights_filter/analysis/llm_assistance.py b/src/rights_filter/analysis/llm_assistance.py index 9ba1c54..fa471f2 100644 --- a/src/rights_filter/analysis/llm_assistance.py +++ b/src/rights_filter/analysis/llm_assistance.py @@ -1,18 +1,49 @@ from __future__ import annotations +import json +import logging import time -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any from rights_filter.analysis.llm_prompts import ( + EMPTY_SUMMARY_TEXT, + EVIDENCE_LABEL_PREFIX, LEGACY_PROMPT_VERSION, + NEEDS_CHECK_HEADING, PROMPT_ID, PROMPT_VERSION, + SUMMARY_HEADING, SYSTEM_PROMPT, ) from rights_filter.domain.records import Evidence, EvidenceSource from rights_filter.integrations.http_json import UrllibJsonTransport +logger = logging.getLogger(__name__) + +# The model reads at most this many evidence rows. Real cases carry ~49 rows and +# dumping all of them produced 13k-62k character prompts that overran the context +# window: the model saw a fraction while the stored summary still claimed every +# row as its source. Capping the input is what makes the citation honest. +MAX_PROMPT_EVIDENCE = 12 + +# Data fields the model is allowed to see. A whitelist rather than a blocklist: +# evidence payloads grow, and a field nobody vetted must not reach the prompt by +# default. Notably absent — promotion_reason (the model copies it verbatim and +# the "summary" becomes a reprint of the evidence list) and the face-crop fields +# (crop geometry and privacy notes are not summarization input). +PROMPT_DATA_FIELDS = ( + "match", + "similarity", + "page_title", + "domain", + "query", + "entity", + "label", + "knowledge_name", + "tier", +) + @dataclass(frozen=True) class LlmSummaryResult: @@ -23,6 +54,11 @@ class LlmSummaryResult: prompt_id: str = "" prompt_version: str = LEGACY_PROMPT_VERSION latency_ms: int = 0 + # Claims that cited at least one real evidence label. Anything the model + # asserted without a valid citation never reaches this list. + claims: tuple[dict[str, Any], ...] = () + dropped_claims: int = 0 + needs_check: tuple[str, ...] = field(default=()) @dataclass @@ -41,10 +77,13 @@ class OllamaGenerateLlmClient: def __init__( self, base_url: str = "http://127.0.0.1:11434", - model: str = "qwen2.5:0.5b-instruct", + model: str = "qwen2.5:3b-instruct", transport: Any | None = None, - timeout: int = 30, + timeout: int = 120, connect_timeout: float = 2, + num_ctx: int = 8192, + num_predict: int = 512, + keep_alive: str = "10m", ) -> None: self.base_url = base_url.rstrip("/") self.model = model @@ -53,17 +92,35 @@ class OllamaGenerateLlmClient: # generation still gets the full `timeout`. self.transport = transport or UrllibJsonTransport(connect_timeout=connect_timeout) self.timeout = timeout + # Ollama defaults the context window to a couple of thousand tokens and + # silently truncates past it. Setting it explicitly is what makes "the + # model read the evidence we cited" true rather than assumed. + self.num_ctx = num_ctx + self.num_predict = num_predict + # Keep the weights resident between cases so the second case in a batch + # does not pay the load again. + self.keep_alive = keep_alive def summarize_evidence(self, evidence: list[Evidence]) -> str: return self.summarize_evidence_detailed(evidence).text def summarize_evidence_detailed(self, evidence: list[Evidence]) -> LlmSummaryResult: + selected = _select_prompt_evidence(evidence) + labels = _evidence_labels(selected) payload = { "model": self.model, "system": SYSTEM_PROMPT, - "prompt": _evidence_prompt(evidence), + "prompt": _evidence_prompt(selected), "stream": False, - "options": {"temperature": 0.1}, + # Constrain decoding to JSON so the citation field is a field, not a + # string the reader has to guess at. + "format": "json", + "keep_alive": self.keep_alive, + "options": { + "temperature": 0.1, + "num_ctx": self.num_ctx, + "num_predict": self.num_predict, + }, } started = time.monotonic() response = self.transport.request_json( @@ -73,14 +130,20 @@ class OllamaGenerateLlmClient: timeout=self.timeout, ) elapsed_ms = int((time.monotonic() - started) * 1000) + + raw = _response_text(response) + claims, dropped, needs_check = _verified_claims(raw, set(labels)) return LlmSummaryResult( - text=_response_text(response), + text=_render_summary(claims, needs_check), # Prefer the model the server says it ran: a tag can resolve to a # different blob than the one configured here. model=str(response.get("model") or self.model), prompt_id=PROMPT_ID, prompt_version=PROMPT_VERSION, latency_ms=elapsed_ms, + claims=claims, + dropped_claims=dropped, + needs_check=needs_check, ) def _generate_url(self) -> str: @@ -113,6 +176,11 @@ class InternalLlmAssistant: "prompt_id": result.prompt_id, "prompt_version": result.prompt_version, "latency_ms": result.latency_ms, + # How much of what the model said survived citation checking. A + # rising dropped count is the signal that the model or the prompt + # has stopped working — without it, fabrication is invisible. + "grounded_claims": len(result.claims), + "dropped_claims": result.dropped_claims, } source_urls = _source_urls(evidence) @@ -178,13 +246,133 @@ def _source_evidence_ids(evidence: list[Evidence]) -> list[str]: return ids -def _evidence_prompt(evidence: list[Evidence]) -> str: +def _prompt_priority(item: Evidence) -> int: + """Lower sorts first. Keeps the rows that carry risk when the cap bites.""" + data = item.data + if item.source == EvidenceSource.FINGERPRINT: + try: + if float(data.get("similarity") or 0) >= 0.9: + return 0 + except (TypeError, ValueError): + pass + return 3 + if item.source == EvidenceSource.WEB_DETECTION: + return 1 if data.get("match") in {"full", "partial", "page"} else 3 + if item.source == EvidenceSource.NAVER_SEARCH: + return 1 if data.get("promoted") else 3 + if item.source == EvidenceSource.FACE_PERSON: + return 2 + return 4 + + +def _select_prompt_evidence(evidence: list[Evidence]) -> list[Evidence]: + ordered = sorted( + (item for item in evidence if item.source != EvidenceSource.LLM_SUMMARY), + key=lambda item: (_prompt_priority(item), -float(item.confidence or 0)), + ) + return ordered[:MAX_PROMPT_EVIDENCE] + + +def _evidence_labels(selected: list[Evidence]) -> dict[str, Evidence]: + return { + f"{EVIDENCE_LABEL_PREFIX}{index}": item + for index, item in enumerate(selected, start=1) + } + + +def _evidence_prompt(selected: list[Evidence]) -> str: lines = ["Source evidence:"] - for index, item in enumerate(evidence, start=1): - lines.append( - f"{index}. source={item.source.value}; reason={item.reason}; " - f"confidence={item.confidence}; data={item.data}" - ) + for label, item in _evidence_labels(selected).items(): + fields = [f"source={item.source.value}", f"reason={item.reason}"] + for key in PROMPT_DATA_FIELDS: + value = item.data.get(key) + if value not in (None, "", [], {}): + fields.append(f"{key}={value}") + lines.append(f"[{label}] " + "; ".join(fields)) + return "\n".join(lines) + + +def _verified_claims( + raw: str, valid_labels: set[str] +) -> tuple[tuple[dict[str, Any], ...], int, tuple[str, ...]]: + """Keep only the claims that cite evidence the model was actually given. + + Verification is a set-membership test on a JSON field, not an attempt to + parse prose. An earlier design checked entities (URLs, numbers) inside free + text, which passes any confident-sounding sentence that happens to mention a + real URL — and so stamps "근거 확인됨" onto exactly the hallucinations it was + meant to catch. + """ + document = _load_json_object(raw) + if document is None: + logger.warning("LLM returned unparseable output; dropping the summary") + return (), 0, () + + claims: list[dict[str, Any]] = [] + dropped = 0 + for entry in document.get("claims") or []: + if not isinstance(entry, dict): + dropped += 1 + continue + text = str(entry.get("text", "")).strip() + cited = _normalized_labels(entry.get("evidence")) + grounded = [label for label in cited if label in valid_labels] + if not text or not grounded: + # No text, or every cited label was invented. Either way there is + # nothing the operator can check it against. + dropped += 1 + continue + claims.append({"text": text, "evidence": grounded}) + + needs_check = tuple( + str(item).strip() + for item in (document.get("needs_check") or []) + if str(item).strip() + ) + return tuple(claims), dropped, needs_check + + +def _load_json_object(raw: str) -> dict[str, Any] | None: + text = (raw or "").strip() + if not text: + return None + try: + parsed = json.loads(text) + except (ValueError, TypeError): + # Some models wrap the object in prose despite format=json. Take the + # outermost brace pair rather than giving up. + start, end = text.find("{"), text.rfind("}") + if start < 0 or end <= start: + return None + try: + parsed = json.loads(text[start : end + 1]) + except (ValueError, TypeError): + return None + return parsed if isinstance(parsed, dict) else None + + +def _normalized_labels(value: Any) -> list[str]: + if isinstance(value, str): + value = [value] + if not isinstance(value, (list, tuple)): + return [] + return [str(item).strip().strip("[]").upper() for item in value if str(item).strip()] + + +def _render_summary( + claims: tuple[dict[str, Any], ...], needs_check: tuple[str, ...] +) -> str: + if not claims and not needs_check: + return EMPTY_SUMMARY_TEXT + + lines: list[str] = [SUMMARY_HEADING] + for claim in claims: + lines.append(f"- {claim['text']} [{', '.join(claim['evidence'])}]") + if not claims: + lines.append("- 인용이 확인된 근거가 없습니다.") + if needs_check: + lines.append(NEEDS_CHECK_HEADING) + lines.extend(f"- {item}" for item in needs_check) return "\n".join(lines) diff --git a/src/rights_filter/analysis/llm_prompts.py b/src/rights_filter/analysis/llm_prompts.py index 1264f38..88d522a 100644 --- a/src/rights_filter/analysis/llm_prompts.py +++ b/src/rights_filter/analysis/llm_prompts.py @@ -12,19 +12,41 @@ output — wording, format contract, or language. from __future__ import annotations PROMPT_ID = "operator-review-note-ko" -PROMPT_VERSION = "ko-v2" +PROMPT_VERSION = "ko-v3-structured" # Marks rows written before prompt versioning existed. Never assume the text # below produced them. LEGACY_PROMPT_VERSION = "legacy" +# Evidence is labelled [E1]..[En] in the prompt and the model cites those labels +# rather than the real evidence ids. The ids are 24-hex-char hashes; asking a +# small model to echo them verbatim invites transcription errors that are +# indistinguishable from fabricated citations. +EVIDENCE_LABEL_PREFIX = "E" + +# Asking for JSON rather than prose is what makes grounding checkable. A claim +# carries the labels it rests on as a field, so verification is a set-membership +# test on that field — not an attempt to parse Korean sentences and guess which +# words a trailing citation covers. SYSTEM_PROMPT = ( - "You draft review notes for an internal image-rights operator, " - "grounded ONLY in the provided source evidence. Reply in Korean. " - "Produce two short parts: (1) '요약:' one or two sentences on what " - "the evidence shows; (2) '확인 필요:' a brief list of what the " - "operator should verify before deciding. Do NOT state or recommend " - "a final decision (approve/hold/reject). Do NOT add any claim that " - "is not supported by the source evidence; if evidence is thin, say " - "so plainly." + "You draft review notes for an internal image-rights operator, grounded " + "ONLY in the labelled source evidence given to you.\n" + "Reply with a JSON object and nothing else, in exactly this shape:\n" + '{"claims": [{"text": "<한국어 문장>", "evidence": ["E1"]}], ' + '"needs_check": ["<한국어 구절>"]}\n' + "Rules:\n" + "- Write every 'text' value in Korean.\n" + "- Every claim MUST cite at least one evidence label it rests on, using " + "only the labels provided. Never invent a label.\n" + "- Never assert anything the cited evidence does not support. If the " + "evidence is thin, say so plainly in a claim citing what little exists.\n" + "- Do NOT state or recommend a final decision (approve / hold / reject).\n" + "- 'needs_check' lists what the operator should verify before deciding.\n" + "- At most 4 claims and 4 needs_check items." ) + +# Rendered headings. Kept here so the prompt contract and the text the operator +# reads move together. +SUMMARY_HEADING = "요약:" +NEEDS_CHECK_HEADING = "확인 필요:" +EMPTY_SUMMARY_TEXT = "요약: 근거를 요약할 수 없습니다." diff --git a/src/rights_filter/integrations/env_clients.py b/src/rights_filter/integrations/env_clients.py index 422a969..203f904 100644 --- a/src/rights_filter/integrations/env_clients.py +++ b/src/rights_filter/integrations/env_clients.py @@ -20,6 +20,8 @@ from rights_filter.integrations.google_custom_search import ( from rights_filter.integrations.naver_search import NaverOpenApiSearchClient, NaverSearchAdapter from rights_filter.integrations.search_policy import SearchApiPolicy +DEFAULT_OLLAMA_MODEL = "qwen2.5:3b-instruct" + @dataclass class ProviderRuntime: @@ -178,7 +180,7 @@ def build_provider_runtime( "llm", "Ollama local LLM", llm_assistant is not None, - f"Ollama local API configured ({env.get('OLLAMA_MODEL', 'qwen2.5:0.5b-instruct')})", + f"Ollama local API configured ({env.get('OLLAMA_MODEL', DEFAULT_OLLAMA_MODEL)})", llm_limit, "없음", "source-linked summary only", @@ -244,8 +246,15 @@ def _google_custom_search_adapter( def _llm_assistant(env: Mapping[str, str], transport: Any | None) -> InternalLlmAssistant | None: client = OllamaGenerateLlmClient( base_url=env.get("OLLAMA_BASE_URL", "http://127.0.0.1:11434"), - model=env.get("OLLAMA_MODEL", "qwen2.5:0.5b-instruct"), + # 0.5B could not hold the output contract: it answered in English and + # reprinted the evidence list instead of summarizing it. Reading evidence + # and citing it needs a 3B-class model. + model=env.get("OLLAMA_MODEL", DEFAULT_OLLAMA_MODEL), transport=transport, + timeout=_bounded_int(env.get("OLLAMA_TIMEOUT"), default=120, minimum=5, maximum=600), + num_ctx=_bounded_int(env.get("OLLAMA_NUM_CTX"), default=8192, minimum=1024, maximum=131072), + num_predict=_bounded_int(env.get("OLLAMA_NUM_PREDICT"), default=512, minimum=64, maximum=4096), + keep_alive=env.get("OLLAMA_KEEP_ALIVE", "10m"), ) return InternalLlmAssistant(client) diff --git a/src/rights_filter/server/sqlite_store.py b/src/rights_filter/server/sqlite_store.py index 458c1f8..3ced6dd 100644 --- a/src/rights_filter/server/sqlite_store.py +++ b/src/rights_filter/server/sqlite_store.py @@ -30,6 +30,7 @@ from rights_filter.server.store_constants import ( DEFAULT_QUERY_COVERAGE_WARN_THRESHOLD, EVIDENCE_OPERATOR_STATUSES, MAX_COVERAGE_THRESHOLD, + MAX_LLM_SUMMARIES_PER_SEED, MIN_COVERAGE_THRESHOLD, NON_CONTRIBUTING_OPERATOR_STATUSES, _bounded_int_env, @@ -291,9 +292,42 @@ class CopyrighterStore( # both classify the same record as new and double-run analysis, external # searches, and audit writes for it. with self._write_lock: - return self._seed_from_image_store_locked(image_store) + created, queue_id = self._seed_from_image_store_locked(image_store) + # LLM narration calls out to Ollama: seconds per case with a real model, + # and a 2s connect timeout per case even when Ollama is not running. + # Doing it under _write_lock meant a folder reload blocked every other + # write for the whole batch — including record_decision, which takes the + # same lock. So the operator's decision button stalled behind narration + # of images they were not even looking at. Narration is only-if-missing + # and therefore safe to finish on a later reload. + if self._narrate_seeded_submissions(queue_id): + # Provider coverage is derived from the evidence rows and is computed + # inside the locked section — i.e. before narration now runs. Without + # this refresh the llm provider reports "not_run" for summaries that + # exist, which is the coverage panel lying about work it did. + with self._write_lock: + self._sync_submission_provider_state(queue_id=queue_id) + return created - def _seed_from_image_store_locked(self, image_store: LocalSubmissionImageStore) -> int: + def _narrate_seeded_submissions(self, queue_id: str) -> int: + if self.provider_runtime.llm_assistant is None: + return 0 + narrated = 0 + for submission in self._all("submissions", queue_id=queue_id): + if narrated >= MAX_LLM_SUMMARIES_PER_SEED: + logger.info( + "LLM narration capped at %s per seed; remaining submissions " + "will be narrated on the next reload", + MAX_LLM_SUMMARIES_PER_SEED, + ) + break + if self._ensure_llm_summary(str(submission["id"]), only_if_missing=True): + narrated += 1 + return narrated + + def _seed_from_image_store_locked( + self, image_store: LocalSubmissionImageStore + ) -> tuple[int, str]: self.purge_expired_face_crops() queue = self.ensure_queue(image_store.root) queue_id = str(queue["id"]) @@ -307,7 +341,6 @@ class CopyrighterStore( if "faceCrops" not in submission: self._sync_face_crops(str(submission["id"]), image_store) self._rescore_all_submissions(queue_id=queue_id) - self._ensure_llm_summaries_for_existing_source_evidence(queue_id=queue_id) self._sync_submission_provider_state(queue_id=queue_id) existing_submission_ids = { @@ -333,7 +366,9 @@ class CopyrighterStore( continue records.append(record) if not records: - return 0 + # Nothing new to analyze, but existing rows may still be missing a + # narration — the caller runs that outside the lock. + return 0, queue_id repository = self._knowledge_repository() analyzer = InternalAnalyzer( @@ -401,7 +436,6 @@ class CopyrighterStore( self._sync_face_crops(record["id"], image_store) self._auto_naver_search(record["id"], query_source_evidence, image_store) self._auto_google_custom_search(record["id"], query_source_evidence, image_store) - self._ensure_llm_summary(record["id"]) self._increment_knowledge_contribution_counts(record["id"], run.evidence) self._rescore_submission(record["id"]) self.add_audit_event( @@ -425,7 +459,7 @@ class CopyrighterStore( """, (int(datetime.now().timestamp()), now, queue_id), ) - return len(records) + return len(records), queue_id def _prune_missing_submission_files( self, diff --git a/src/rights_filter/server/store_constants.py b/src/rights_filter/server/store_constants.py index 1642100..90af74b 100644 --- a/src/rights_filter/server/store_constants.py +++ b/src/rights_filter/server/store_constants.py @@ -48,6 +48,11 @@ DEFAULT_QUERY_COVERAGE_WARN_THRESHOLD = 40 MIN_COVERAGE_THRESHOLD = 0 MAX_COVERAGE_THRESHOLD = 100 DEFAULT_FACE_CROP_RETENTION_DAYS = 90 +# Upper bound on LLM narrations generated by one seed/reload. A folder import can +# bring in hundreds of submissions; narrating all of them in one request makes +# the reload take as long as the model does, times the batch. The rest are picked +# up by the next reload (narration is only-if-missing and therefore resumable). +MAX_LLM_SUMMARIES_PER_SEED = 20 PAYLOAD_REQUIRED_FIELDS = { "submissions": { "id", diff --git a/tests/rights_filter/analysis/test_llm_grounding.py b/tests/rights_filter/analysis/test_llm_grounding.py new file mode 100644 index 0000000..a71a2c1 --- /dev/null +++ b/tests/rights_filter/analysis/test_llm_grounding.py @@ -0,0 +1,198 @@ +"""Structured evidence reading: the model cites, and the citation is checked. + +The assistant no longer returns free prose. It returns claims that each carry +the evidence labels they rest on, and a claim whose labels were invented is +dropped before it can reach the operator. These tests pin that boundary, the +prompt input cap, and the field whitelist. +""" + +import json + +import pytest + +from rights_filter.analysis.llm_assistance import ( + MAX_PROMPT_EVIDENCE, + InternalLlmAssistant, + OllamaGenerateLlmClient, + _evidence_prompt, + _select_prompt_evidence, +) +from rights_filter.domain.records import Evidence, EvidenceSource + + +class _ScriptedTransport: + """Returns a canned Ollama response and records the request payload.""" + + def __init__(self, document): + self.sent = {} + self._body = document if isinstance(document, str) else json.dumps(document) + + def request_json(self, method, url, payload=None, timeout=None): + self.sent = dict(payload or {}) + return {"response": self._body, "model": "qwen2.5:3b-instruct"} + + +def _client(document) -> tuple[OllamaGenerateLlmClient, _ScriptedTransport]: + transport = _ScriptedTransport(document) + return OllamaGenerateLlmClient(transport=transport), transport + + +def _web(match="full", url="https://example.test/a", **data): + return Evidence( + source=EvidenceSource.WEB_DETECTION, + reason="Web detection match", + confidence=0.9, + data={"match": match, "result_url": url, "evidence_id": "ev-1", **data}, + ) + + +# --- citation verification --------------------------------------------------- + + +def test_claim_citing_real_evidence_survives(): + client, _ = _client( + {"claims": [{"text": "동일 이미지가 외부 페이지에 있습니다.", "evidence": ["E1"]}], + "needs_check": ["출처 페이지의 라이선스 표기"]} + ) + + result = client.summarize_evidence_detailed([_web()]) + + assert result.dropped_claims == 0 + assert len(result.claims) == 1 + assert result.claims[0]["evidence"] == ["E1"] + assert "동일 이미지가 외부 페이지에 있습니다." in result.text + assert "출처 페이지의 라이선스 표기" in result.text + + +def test_claim_citing_an_invented_label_is_dropped(): + # The failure mode that matters: a confident sentence attached to evidence + # that was never shown to the model. + client, _ = _client( + {"claims": [ + {"text": "실제 근거.", "evidence": ["E1"]}, + {"text": "지어낸 근거에 붙은 단정.", "evidence": ["E9"]}, + ]} + ) + + result = client.summarize_evidence_detailed([_web()]) + + assert result.dropped_claims == 1 + assert [claim["text"] for claim in result.claims] == ["실제 근거."] + assert "지어낸" not in result.text + + +def test_claim_without_any_citation_is_dropped(): + client, _ = _client({"claims": [{"text": "근거 없는 단정.", "evidence": []}]}) + + result = client.summarize_evidence_detailed([_web()]) + + assert result.dropped_claims == 1 + assert result.claims == () + assert "근거 없는 단정" not in result.text + + +def test_unparseable_output_yields_no_claims_and_no_prose(): + # A model that ignores the format contract must not have its prose shown as + # if it had been checked. + client, _ = _client("I think this image is fine, it looks authentic.") + + result = client.summarize_evidence_detailed([_web()]) + + assert result.claims == () + assert "authentic" not in result.text + + +def test_json_wrapped_in_prose_is_still_read(): + client, _ = _client( + 'Sure! {"claims": [{"text": "근거 있음.", "evidence": ["E1"]}]} hope that helps' + ) + + result = client.summarize_evidence_detailed([_web()]) + + assert len(result.claims) == 1 + + +def test_partially_valid_citation_list_keeps_only_real_labels(): + client, _ = _client( + {"claims": [{"text": "혼합 인용.", "evidence": ["E1", "E42"]}]} + ) + + result = client.summarize_evidence_detailed([_web()]) + + assert result.claims[0]["evidence"] == ["E1"] + + +# --- prompt input boundary --------------------------------------------------- + + +def test_prompt_is_capped_and_keeps_the_rows_that_carry_risk(): + scoring = [_web(match="full", url=f"https://example.test/{index}") for index in range(4)] + noise = [ + Evidence(EvidenceSource.SEARCH_SKIPPED, "quota reached", 0.0, {}) + for _ in range(MAX_PROMPT_EVIDENCE + 10) + ] + + selected = _select_prompt_evidence(noise + scoring) + + assert len(selected) == MAX_PROMPT_EVIDENCE + assert all(item in selected for item in scoring) + + +def test_prompt_excludes_unvetted_and_privacy_fields(): + evidence = _web( + promotion_reason="이 값을 그대로 베끼면 요약이 증거 목록 복붙이 된다", + privacy_note="동일 인물 판정은 아닙니다", + crop_index=2, + thumbnail_url="https://example.test/thumb.jpg", + page_title="허용되는 제목", + ) + + prompt = _evidence_prompt([evidence]) + + assert "허용되는 제목" in prompt + assert "복붙" not in prompt + assert "privacy_note" not in prompt + assert "crop_index" not in prompt + assert "thumbnail_url" not in prompt + + +def test_prompt_labels_evidence_so_the_model_can_cite_it(): + prompt = _evidence_prompt(_select_prompt_evidence([_web(), _web(url="https://b.test")])) + + assert "[E1]" in prompt + assert "[E2]" in prompt + + +def test_llm_summary_evidence_is_never_fed_back_into_the_prompt(): + prior = Evidence(EvidenceSource.LLM_SUMMARY, "prior summary", 0.0, {"summary": "이전 요약"}) + + selected = _select_prompt_evidence([prior, _web()]) + + assert all(item.source != EvidenceSource.LLM_SUMMARY for item in selected) + + +# --- request shape ----------------------------------------------------------- + + +def test_request_pins_the_context_window_and_asks_for_json(): + client, transport = _client({"claims": []}) + + client.summarize_evidence_detailed([_web()]) + + assert transport.sent["format"] == "json" + assert transport.sent["options"]["num_ctx"] == client.num_ctx + assert transport.sent["keep_alive"] == client.keep_alive + + +@pytest.mark.parametrize("field", ["grounded_claims", "dropped_claims"]) +def test_assistant_records_how_much_survived_verification(field): + client, _ = _client( + {"claims": [ + {"text": "진짜.", "evidence": ["E1"]}, + {"text": "가짜.", "evidence": ["E7"]}, + ]} + ) + + evidence = InternalLlmAssistant(client).summarize("SUB-1", [_web()]) + + assert evidence.data[field] == 1 diff --git a/tests/rights_filter/integrations/test_env_clients.py b/tests/rights_filter/integrations/test_env_clients.py index 34e0b44..8f0d2f5 100644 --- a/tests/rights_filter/integrations/test_env_clients.py +++ b/tests/rights_filter/integrations/test_env_clients.py @@ -3,7 +3,7 @@ import base64 from rights_filter.analysis.preprocessing import ImagePayload from rights_filter.domain.records import Evidence, EvidenceSource from rights_filter.integrations.cloud_vision_web_detection import GoogleVisionRestClient -from rights_filter.integrations.env_clients import build_provider_runtime +from rights_filter.integrations.env_clients import DEFAULT_OLLAMA_MODEL, build_provider_runtime from rights_filter.integrations.google_custom_search import GoogleCustomSearchClient from rights_filter.integrations.naver_search import NaverOpenApiSearchClient from rights_filter.analysis.llm_assistance import OllamaGenerateLlmClient @@ -130,8 +130,12 @@ def test_google_custom_search_client_uses_env_key_and_search_engine_id(): assert "searchType=image" in call["url"] -def test_ollama_generate_client_uses_local_endpoint_and_returns_response_text(): - transport = FakeJsonTransport({"response": "Source-linked summary"}) +def test_ollama_generate_client_uses_local_endpoint_and_renders_verified_claims(): + # The client no longer passes the model's prose through: it reads the + # structured claims and renders only the ones whose citations check out. + transport = FakeJsonTransport( + {"response": '{"claims": [{"text": "출처 페이지가 확인됩니다.", "evidence": ["E1"]}]}'} + ) client = OllamaGenerateLlmClient( base_url="http://localhost:11434", model="qwen2.5:0.5b-instruct", @@ -149,7 +153,8 @@ def test_ollama_generate_client_uses_local_endpoint_and_returns_response_text(): summary = client.summarize_evidence(evidence) call = transport.calls[0] - assert summary == "Source-linked summary" + assert "출처 페이지가 확인됩니다." in summary + assert "[E1]" in summary assert call["method"] == "POST" assert call["url"] == "http://localhost:11434/api/generate" assert call["headers"] == {} @@ -274,4 +279,6 @@ def test_provider_runtime_leaves_missing_env_providers_disabled(): "GOOGLE_CUSTOM_SEARCH_CX": False, } assert runtime.provider_payloads["llm"]["enabled"] is True - assert runtime.provider_payloads["llm"]["compliance"] == "Ollama local API configured (qwen2.5:0.5b-instruct)" + assert runtime.provider_payloads["llm"]["compliance"] == ( + f"Ollama local API configured ({DEFAULT_OLLAMA_MODEL})" + ) diff --git a/tests/rights_filter/server/test_sqlite_store.py b/tests/rights_filter/server/test_sqlite_store.py index c31eb75..7f7deb5 100644 --- a/tests/rights_filter/server/test_sqlite_store.py +++ b/tests/rights_filter/server/test_sqlite_store.py @@ -7482,7 +7482,11 @@ def test_sqlite_store_rerun_enrichment_uses_ollama_llm_when_configured(tmp_path: def test_sqlite_store_seed_auto_runs_ollama_llm_when_source_evidence_exists(tmp_path: Path): - transport = FakeJsonTransport({"response": "Seed-time source summary"}) + # The assistant reads structured claims now; prose without citations is + # dropped before it can be stored. + transport = FakeJsonTransport( + {"response": '{"claims": [{"text": "시드 시점 근거 요약.", "evidence": ["E1"]}]}'} + ) runtime = build_provider_runtime( env={"OLLAMA_BASE_URL": "http://localhost:11434", "OLLAMA_MODEL": "qwen2.5:0.5b-instruct"}, transport=transport,