Add an LLM fact-check pass over the generated briefing
After summarisation the report is verified against the collected payload (numbers, directions, unsupported claims) by a second LLM call with a Verification JSON schema. Major issues trigger one revision and a re-check; minor-only verdicts are normalized to ok. Failures never block delivery. The verdict is stored in the result JSON and shown on the dashboard (badge + 검증 메모). Disable with US2KR_VERIFY_ENABLED. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+106
-38
@@ -9,12 +9,19 @@ import re
|
||||
from typing import Any, get_origin
|
||||
|
||||
import openai
|
||||
from pydantic import ValidationError
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from us2kr.config import Settings
|
||||
from us2kr.llm.base import LLMError
|
||||
from us2kr.llm.prompts import SYSTEM_PROMPT, build_user_message
|
||||
from us2kr.models import MarketPayload, Report
|
||||
from us2kr.llm.prompts import (
|
||||
REVISE_INSTRUCTION,
|
||||
SYSTEM_PROMPT,
|
||||
VERIFY_SYSTEM_PROMPT,
|
||||
build_user_message,
|
||||
build_verify_message,
|
||||
format_issues,
|
||||
)
|
||||
from us2kr.models import MarketPayload, Report, Verification, VerificationIssue
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -46,16 +53,24 @@ def _strip_titles(node: Any) -> None:
|
||||
_strip_titles(value)
|
||||
|
||||
|
||||
def report_json_schema() -> dict[str, Any]:
|
||||
"""JSON schema for `Report` suitable for `response_format` (no titles, closed object)."""
|
||||
schema = copy.deepcopy(Report.model_json_schema())
|
||||
def model_json_schema(model_cls: type[BaseModel]) -> dict[str, Any]:
|
||||
"""JSON schema for a pydantic model suitable for `response_format` (no titles, closed object)."""
|
||||
schema = copy.deepcopy(model_cls.model_json_schema())
|
||||
_strip_titles(schema)
|
||||
schema["additionalProperties"] = False
|
||||
return schema
|
||||
|
||||
|
||||
def model_response_format(model_cls: type[BaseModel], name: str) -> dict[str, Any]:
|
||||
return {"type": "json_schema", "json_schema": {"name": name, "schema": model_json_schema(model_cls)}}
|
||||
|
||||
|
||||
def report_json_schema() -> dict[str, Any]:
|
||||
return model_json_schema(Report)
|
||||
|
||||
|
||||
def report_response_format() -> dict[str, Any]:
|
||||
return {"type": "json_schema", "json_schema": {"name": "report", "schema": report_json_schema()}}
|
||||
return model_response_format(Report, "report")
|
||||
|
||||
|
||||
def _first_json_object(text: str) -> str | None:
|
||||
@@ -108,9 +123,15 @@ def extract_json_text(raw: str) -> str:
|
||||
return text
|
||||
|
||||
|
||||
_LIST_FIELDS: frozenset[str] = frozenset(
|
||||
name for name, info in Report.model_fields.items() if get_origin(info.annotation) is list
|
||||
)
|
||||
def _list_fields(model_cls: type[BaseModel]) -> frozenset[str]:
|
||||
"""Names of the model's plain string-list fields (targets for the string->list coercion)."""
|
||||
return frozenset(
|
||||
name
|
||||
for name, info in model_cls.model_fields.items()
|
||||
if get_origin(info.annotation) is list and info.annotation == list[str]
|
||||
)
|
||||
|
||||
|
||||
_BULLET_SPLIT = re.compile(r"(?:^|\n)\s*(?:[-*•]|\d+[.)])\s+")
|
||||
_SENTENCE_SPLIT = re.compile(r"(?<=[.!?。])\s+")
|
||||
|
||||
@@ -125,8 +146,8 @@ def _split_prose(text: str) -> list[str]:
|
||||
return parts or [text.strip()]
|
||||
|
||||
|
||||
def coerce_report_data(data: Any) -> tuple[Any, list[str]]:
|
||||
"""Fix the common MiniMax schema slip of emitting list fields as a single string.
|
||||
def coerce_model_data(data: Any, model_cls: type[BaseModel]) -> tuple[Any, list[str]]:
|
||||
"""Fix the common MiniMax schema slip of emitting string-list fields as a single string.
|
||||
|
||||
Returns (data, names of coerced fields). Anything that is not a dict is returned untouched so that
|
||||
pydantic can report the real error.
|
||||
@@ -134,7 +155,7 @@ def coerce_report_data(data: Any) -> tuple[Any, list[str]]:
|
||||
if not isinstance(data, dict):
|
||||
return data, []
|
||||
coerced: list[str] = []
|
||||
for name in _LIST_FIELDS:
|
||||
for name in _list_fields(model_cls):
|
||||
value = data.get(name)
|
||||
if isinstance(value, str):
|
||||
data[name] = _split_prose(value)
|
||||
@@ -142,17 +163,25 @@ def coerce_report_data(data: Any) -> tuple[Any, list[str]]:
|
||||
return data, coerced
|
||||
|
||||
|
||||
def parse_report(raw: str) -> Report:
|
||||
"""Turn raw model content into a validated Report (raises ValueError / ValidationError)."""
|
||||
def coerce_report_data(data: Any) -> tuple[Any, list[str]]:
|
||||
return coerce_model_data(data, Report)
|
||||
|
||||
|
||||
def parse_model[T: BaseModel](raw: str, model_cls: type[T]) -> T:
|
||||
"""Turn raw model content into a validated instance (raises ValueError / ValidationError)."""
|
||||
text = extract_json_text(raw)
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return Report.model_validate_json(text) # let pydantic produce the canonical error
|
||||
data, coerced = coerce_report_data(data)
|
||||
return model_cls.model_validate_json(text) # let pydantic produce the canonical error
|
||||
data, coerced = coerce_model_data(data, model_cls)
|
||||
if coerced:
|
||||
log.warning("coerced string -> list for report fields: %s", ", ".join(sorted(coerced)))
|
||||
return Report.model_validate(data)
|
||||
log.warning("coerced string -> list for %s fields: %s", model_cls.__name__, ", ".join(sorted(coerced)))
|
||||
return model_cls.model_validate(data)
|
||||
|
||||
|
||||
def parse_report(raw: str) -> Report:
|
||||
return parse_model(raw, Report)
|
||||
|
||||
|
||||
class MiniMaxProvider:
|
||||
@@ -174,7 +203,6 @@ class MiniMaxProvider:
|
||||
max_retries=2,
|
||||
)
|
||||
self._client = client
|
||||
self._response_format = report_response_format()
|
||||
|
||||
@property
|
||||
def models(self) -> list[str]:
|
||||
@@ -182,28 +210,59 @@ class MiniMaxProvider:
|
||||
|
||||
# ------------------------------------------------------------------ public
|
||||
def summarize(self, payload: MarketPayload) -> tuple[Report, str]:
|
||||
user_message = build_user_message(payload)
|
||||
base_messages: list[dict[str, Any]] = [
|
||||
messages: list[dict[str, Any]] = [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_message},
|
||||
{"role": "user", "content": build_user_message(payload)},
|
||||
]
|
||||
attempts: list[str] = []
|
||||
for model in self._models:
|
||||
result = self._attempt_model(model, base_messages, attempts)
|
||||
if result is not None:
|
||||
return result, model
|
||||
raise LLMError("all MiniMax models failed: " + " | ".join(attempts))
|
||||
return self._run(messages, Report, "report", task="summarize")
|
||||
|
||||
def verify(self, payload: MarketPayload, report: Report) -> tuple[Verification, str]:
|
||||
"""Fact-check `report` against the payload. Raise LLMError on total failure."""
|
||||
messages: list[dict[str, Any]] = [
|
||||
{"role": "system", "content": VERIFY_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": build_verify_message(payload, report)},
|
||||
]
|
||||
return self._run(messages, Verification, "verification", task="verify")
|
||||
|
||||
def revise(
|
||||
self, payload: MarketPayload, report: Report, issues: list[VerificationIssue]
|
||||
) -> tuple[Report, str]:
|
||||
"""Ask for a corrected Report given verification issues. Raise LLMError on total failure."""
|
||||
messages: list[dict[str, Any]] = [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": build_user_message(payload)},
|
||||
{"role": "assistant", "content": report.model_dump_json()},
|
||||
{"role": "user", "content": REVISE_INSTRUCTION + format_issues(issues)},
|
||||
]
|
||||
return self._run(messages, Report, "report", task="revise")
|
||||
|
||||
# ----------------------------------------------------------------- helpers
|
||||
def _attempt_model(
|
||||
self, model: str, messages: list[dict[str, Any]], attempts: list[str]
|
||||
) -> Report | None:
|
||||
def _run[T: BaseModel](
|
||||
self, messages: list[dict[str, Any]], model_cls: type[T], schema_name: str, task: str
|
||||
) -> tuple[T, str]:
|
||||
"""Try every configured model in order; return (parsed instance, model id)."""
|
||||
response_format = model_response_format(model_cls, schema_name)
|
||||
attempts: list[str] = []
|
||||
for model in self._models:
|
||||
result = self._attempt_model(model, messages, attempts, model_cls, response_format)
|
||||
if result is not None:
|
||||
return result, model
|
||||
raise LLMError(f"all MiniMax models failed ({task}): " + " | ".join(attempts))
|
||||
|
||||
def _attempt_model[T: BaseModel](
|
||||
self,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
attempts: list[str],
|
||||
model_cls: type[T],
|
||||
response_format: dict[str, Any],
|
||||
) -> T | None:
|
||||
"""One model: initial call plus at most one repair retry. Returns None when the model failed."""
|
||||
content = self._call(model, messages, attempts)
|
||||
content = self._call(model, messages, attempts, response_format)
|
||||
if content is None:
|
||||
return None
|
||||
try:
|
||||
return parse_report(content)
|
||||
return parse_model(content, model_cls)
|
||||
except (ValidationError, ValueError) as exc:
|
||||
error_text = _short(str(exc), 800)
|
||||
attempts.append(f"{model}: invalid report ({_short(str(exc), 160)})")
|
||||
@@ -218,18 +277,23 @@ class MiniMaxProvider:
|
||||
{"role": "assistant", "content": _short(content, 12000)},
|
||||
{"role": "user", "content": REPAIR_INSTRUCTION + error_text},
|
||||
]
|
||||
content = self._call(model, repair_messages, attempts, label="repair")
|
||||
content = self._call(model, repair_messages, attempts, response_format, label="repair")
|
||||
if content is None:
|
||||
return None
|
||||
try:
|
||||
return parse_report(content)
|
||||
return parse_model(content, model_cls)
|
||||
except (ValidationError, ValueError) as exc:
|
||||
attempts.append(f"{model} (repair): invalid report ({_short(str(exc), 160)})")
|
||||
log.warning("model %s repair retry still invalid: %s", model, _short(str(exc), 300))
|
||||
return None
|
||||
|
||||
def _call(
|
||||
self, model: str, messages: list[dict[str, Any]], attempts: list[str], label: str = "initial"
|
||||
self,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
attempts: list[str],
|
||||
response_format: dict[str, Any],
|
||||
label: str = "initial",
|
||||
) -> str | None:
|
||||
"""Perform one chat completion. Returns content text, or None (attempt recorded) on failure."""
|
||||
tag = model if label == "initial" else f"{model} ({label})"
|
||||
@@ -241,7 +305,7 @@ class MiniMaxProvider:
|
||||
model=model,
|
||||
messages=messages,
|
||||
max_completion_tokens=self._settings.minimax_max_completion_tokens,
|
||||
response_format=self._response_format,
|
||||
response_format=response_format,
|
||||
extra_body=extra_body,
|
||||
)
|
||||
except OPENAI_ERRORS as exc:
|
||||
@@ -297,8 +361,12 @@ def _short(text: str, limit: int) -> str:
|
||||
__all__ = [
|
||||
"OPENAI_ERRORS",
|
||||
"MiniMaxProvider",
|
||||
"coerce_model_data",
|
||||
"coerce_report_data",
|
||||
"extract_json_text",
|
||||
"model_json_schema",
|
||||
"model_response_format",
|
||||
"parse_model",
|
||||
"parse_report",
|
||||
"report_json_schema",
|
||||
"report_response_format",
|
||||
|
||||
Reference in New Issue
Block a user