diff --git a/README.md b/README.md index 5a9247f..70927f0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +현재 구현: [질문별 브리핑 조합·근거 검증](docs/changes/question-briefing-composition.md). 새 분석 영수증을 출처가 추적되는 요약·비교·타임라인 제안으로 묶습니다. 불확실성과 미반영 관련 명제를 보존하는 순수 계약이며 최종 생성·저장/API/UI·개발 적용은 후속입니다. 신규97개·새 전체9,886개·정적/타입/빌드/OpenAPI·독립 검토를 통과했습니다. + 현재 구현: [질문별 자동 분석·결과 수집](docs/changes/question-plan-runner.md). 한 실행에서 자료별 완료를 기다리고 다음 분석으로 이어갑니다. 모든 완료 영수증을 현재 권한·원문으로 다시 검증해 모으며, 브리핑 합성·API/UI·개발 적용은 후속입니다. 신규73/집중78·새 전체9,789개·정적/타입/빌드/OpenAPI·독립 검토 C0I0M0를 통과했습니다. 현재 구현: [질문별 순차 AI 작업 연결](docs/changes/question-plan-dispatch.md). 고정한 자료를 순서대로 기존 영속 작업으로 접수하고, 같은 요청은 완료한 분석을 재사용합니다. 기존 활성 작업1개·합산 예약 예산을 유지하며 브리핑 변환·자동 진행·API/UI·개발 적용은 후속입니다. 집중91/새 전체9,716개·정적/타입/빌드/OpenAPI·독립 최종 수정 검토 C0I0M0를 통과했습니다. diff --git a/docs/README.md b/docs/README.md index 647b1a2..6de2f61 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,3 +1,5 @@ +현재 구현: [질문별 브리핑 조합·근거 검증](changes/question-briefing-composition.md). 원본 명제·조건·누락 coverage를 유지하고 자유 문장을 미검토로 표시하는 별도 조합 계약입니다. 신규97개·새 전체9,886개·정적/타입/빌드/OpenAPI·독립 검토를 통과했습니다. + 현재 구현: [질문별 자동 분석·결과 수집](changes/question-plan-runner.md). 한 실행에서 자료별 완료를 기다리고 다음 분석으로 이어갑니다. 모든 완료 영수증을 현재 권한·원문으로 다시 검증해 모으며, 브리핑 합성·API/UI·개발 적용은 후속입니다. 신규73/집중78·새 전체9,789개·정적/타입/빌드/OpenAPI·독립 검토 C0I0M0를 통과했습니다. 현재 구현: [질문별 순차 AI 작업 연결](changes/question-plan-dispatch.md). 질문 검색의 고정 선택과 기존 작업/queue를 연결하며 신규 외부 모델 호출이나 앱 활성화는 이번 검증에 포함하지 않습니다. 집중91/새 전체9,716개·정적/타입/빌드/OpenAPI·독립 최종 수정 검토 C0I0M0를 통과했습니다. diff --git a/docs/changes/question-briefing-composition.md b/docs/changes/question-briefing-composition.md new file mode 100644 index 0000000..76892bd --- /dev/null +++ b/docs/changes/question-briefing-composition.md @@ -0,0 +1,29 @@ +# 질문별 분석을 근거가 추적되는 브리핑으로 조합 + +2026-10-26. [자동 분석·완료 수집](question-plan-runner.md) 다음 단계. 현재 작업은 새 분석 영수증에서 사용 가능한 문장·비교·시간축 제안을 검증하는 순수 조합 계약이다. 실제 최종 모델 송신·영속 저장·API/UI 연결·개발 적용과 구분한다. 기존 단일 출처 해석을 확인된 사실로 승격하지 않는다. + +## Global Constraints + +- NEW execution files ONLY: packages/agents/src/issuedigest_agents/question_briefing.py and packages/agents/tests/test_question_briefing.py. All existing tracked non-Markdown files and previous runner proof files byte-identical. No dependency/export/config/DB/migration/API/UI changes, provider calls, model clients, DBOS registration, operational server/DB, main merge or app activation. Pure composition only. Do not masquerade as historical research-v10 or canonical Canvas/human review/public publication. +- Public entry points build_question_briefing(question, sources:tuple[Source,...], receipts:tuple[GuardedQuestionEvidenceGenerationV2,...], proposal:QuestionBriefingProposal|dict)->QuestionBriefing and validate_question_briefing(question,sources,receipts,result:QuestionBriefing|dict)->QuestionBriefing. Canonical Source is existing snapshot/EvidenceBundle/SourcePresentation tuple. Source count1..5, unique snapshot/document-version/citation IDs, same workspace, receipt count exact order. Use unchanged validate_guarded_question_evidence_v2 for current quote/question/instructions/prompt/usage validity. Existing receipt evidence.source_sha256 already binds the COMPLETE canonical source dict (snapshot_id, validated EvidenceBundle and SourcePresentation JSON via unchanged question_evidence_propositions._source). Reuse that helper/digest and expose source_sha256 on the new source output; do not invent another hash algorithm or claim the prompt alone binds metadata. Validate all actual input types before normalization (no bool/int coercion, subclasses/extra/missing fields/model_construct/model_copy drift), use existing guards narrowly. Preserve existing validators' sanitized internals but new boundary fixed QuestionBriefingError with no sensitive text/cause/context. No logging. Question uses original exact nonblank str length1..1500 (no trimming). +- Strict/frozen/extra-forbid proposal models. QuestionBriefingProposal outcome answer/insufficient_evidence, message required nullable max500, modules tuple max3. Answer requires modules, no message, first exactly one summary (no later summary), summary claims1..4. Other modules comparison (2..4 claims) or timeline(1..4). Titles nonblank<=100, texts nonblank<=400. A claim has text, references tuple1..8 of unique local reference {snapshot_id:UUID4,proposition_id:p1..p24}, event_date:date|None REQUIRED. Timeline requires date; other modules forbid dates. No category supplied by model. No HTML/executable module types or arbitrary action fields; text remains plain untrusted text for downstream safe renderer, not HTML certification. +- A reference must resolve to the corresponding CURRENT validated receipt and to its question-linked proposition OR a transitive dependency of a linked proposition. Compute closure without changing order/graphs; reject unknown/duplicate refs, unrelated proposition use, cross-source local-ID collision, and cycles through existing receipt guard. Claim output epistemic_status is source_statement only if EVERY referenced proposition AND ALL their transitive dependencies are source_statement, else uncertain (never documented/fact/human-reviewed). No caller category input. Recheck full source receipts, not only referenced ones. Do not flatten facets or numeric conditions into fabricated facts; retain all original validated receipt evidence in output with full source identity and prompt/instruction/usage provenance. +- QuestionBriefing profile question-briefing-v1/status unreviewed; binds question SHA256, workspace UUID4, sources tuple (snapshot_id, source_sha256 of full canonical source, receipt actual typed full object), outcome/message and derived modules/claims. Store original proposal in full to allow exact rebuild. Derived claim retains text, references, event_date and derived epistemic_status. Coverage per source contains ALL linked proposition IDs and closure IDs in original proposition order plus used IDs in original order, including transitive deps used by claims; unrepresented linked IDs explicit (uncovered_linked_proposition_ids) and unresolved_reason EXACT receipt value. A partial selection must never look exhaustive. An insufficient_evidence result has no modules and message, but preserves all source receipts and coverage; an answer requires at least one referenced question-linked proposition somewhere (dependency-only answers rejected) and cannot have zero linked evidence across all sources. No arbitrary source dropping/no_match empty source pseudo-briefing. Same inputs/proposal deterministic value equality/JSON roundtrip; no random UUID/timestamp. +- Timeline event_date must have exact ISO YYYY-MM-DD literal in a supporting_refs span of at least one directly referenced proposition; source metadata/subject/facet-only unrelated supporting quote/prior model date not sufficient. It is a text anchor, NOT actual event-date semantic approval. No inferred dates or time facets converted automatically. +- Revalidate saved output by strict raw shape/type check and reconstruct from its stored proposal/current question/source/receipts, compare ENTIRE output; reject status/profile/category/text/date/order/receipt/hash/coverage/metadata mutation; don't silently repair saved output. New error raised OUTSIDE exception handler so cause/context absent. No partial output. Rebuild equality establishes source/structure consistency, not cryptographic origin: replacing proposal and matching derived prose together may form another valid unreviewed composition; persistence/authentication must protect the authoritative stored record. +- TDD RED missing module then minimal GREEN. Test genuine multi-source composition incl reused local p1, mixed uncertainty and transitive dependency, partial coverage/insufficient states; wrong source/question/prompt/instructions/receipt use, dates only in metadata/unrelated quote, forged hashes/coverage/categories, raw type/subclass/missing/extra/model_copy corruption; deterministic serialization roundtrip; zero networking/import activation. Scope tests use existing synthetic fixtures and restore introduced module/package attrs/sys.path fixture. No old tests/fixtures modified. Scoped Ruff/format/mypy; controller full npm run check and independent task/final review. Agent does not spawn agents. + +## Task 1 + +- [x] Implement new2files with TDD and full strict original-input/rebuild validation, derived uncertainty/coverage/exact date anchors; scoped checks, self review and commit only new2files plus this checkbox. Write report in current SDD workspace with RED/GREEN evidence, commands/results/interfaces and limitations. + +## Controller gates + +- [x] Independent task/whole-branch review C0I0M0; full 9,886 tests/lint/type/build/OpenAPI; preservation494/44. +- [x] privacy/docs/Draft PR115/Sitesv137/previousv136+7exactdocs/ownedQA1+volume1 cleanup final handoff. +- [ ] Actual final-generation durable admission/provider meaning, storage/API/UI and development acceptance (outside this change). + +## 판단과 비용 + +1. 원래 명제·조건·누락 coverage를 함께 보존하고 자유 문장은 미검토로 유지한다. 인용 연결만으로 의미 정확도를 인증하지 않으며 실제 모델 품질 검증은 별도로 필요하다. +2. 과거 Canvas 버전으로 위장하지 않는 별도 조합 계약을 둔다. 구형 화면에 즉시 연결되지 않으며 최종 모델·저장·표시 연결 작업이 남는다. diff --git a/docs/planning/implementation-plan.md b/docs/planning/implementation-plan.md index 0f6ab6e..de98d0e 100644 --- a/docs/planning/implementation-plan.md +++ b/docs/planning/implementation-plan.md @@ -1,3 +1,16 @@ +## 현재 구현 · 새 분석을 브리핑 제안으로 조합 + +신규97·새 전체9,886개/정적/타입/빌드/OpenAPI·독립 검토 통과. [별도 조합 계약](../changes/question-briefing-composition.md)으로 문장·명제·출처를 연결하고 불확실성·미반영 근거를 유지합니다. 원문/영수증에서 제안 전체를 재검증하며 역사 Canvas의 모델·검토 버전을 재사용하지 않습니다. 최종 모델 송신과 영속 저장/API/UI·개발 적용은 별도입니다. 기존494실행·이전44증거 유지, 실제 제공자/운영 변경0입니다. + + +### 다음 연결 순서 + +1. **최종 생성 실행:** 검증한 자료별 영수증·원래 조건을 새 최종 모델 입력으로 연결한다. 기존 공유 비용 상한·현재 권한·원문 재검사를 유지하고 송신 여부가 불명확하면 자동 재호출하지 않는다. 모델 입력 한도와 실제 의미 품질을 별도로 검증한다. +2. **브리핑 저장·재열람:** 원래 제안과 조합 결과·출처 버전·사용량을 권한이 있는 서버 경로에서 함께 저장한다. 과거 모델/Canvas 이름을 새 실행에 붙이지 않으며 같은 요청의 재열람은 추가 생성 없이 처리한다. 이번 순수 재구성 검사를 저장 기록의 인증·불변성으로 오해하지 않는다. +3. **제품 연결:** 버전이 있는 API를 통해 짧은 브리핑·필요한 비교/변화·정확 근거를 표시한다. 질문 후 생성 중 상태·실패/취소·재열람까지 연결하고 휴대폰/PC 사용자 여정을 확인한 뒤 개인 개발판에 반영한다. 공개 게시를 자동화하지 않는다. + +위 항목은 연결 순서이며 아직 구현/배포 완료가 아니다. 별도 관리 화면·임시 확인 UI는 추가하지 않는다. + ## M3 진행 · 자동 분석·검증 결과 수집 완료 [자동 분석 진행 계약](../changes/question-plan-runner.md)은 신규73/집중78·새 전체9,789개·정적/타입/빌드/OpenAPI·독립 검토 C0I0M0를 통과했다. 기존 작업 완료를 기다려 같은 요청으로 다음 자료를 진행한다. 전체 대기와 진행 횟수를 제한하며 timeout/cancel 뒤 기존 작업은 유지한다. 현재 권한/원문과 모든 완료 영수증을 재검증해 브리핑 합성 입력을 만든다. 독립 부모 복구·브리핑/API/UI·실제 의미·개발 적용은 후속이다. 실제 제공자/운영/앱 활성화0이다. diff --git a/docs/planning/progress.md b/docs/planning/progress.md index ef1b70a..a352181 100644 --- a/docs/planning/progress.md +++ b/docs/planning/progress.md @@ -1,3 +1,17 @@ +## 최종 인계 · 질문별 브리핑 조합 · 2026-10-26 + +[Draft PR115](https://github.com/DigestWorks/IssueDigest/pull/115)은 feat/question-plan-runner 기반·미병합이다. 실행 ac7a914, 신규97/새 전체9,886개(Python9,066+JS820)·정적/타입/빌드/OpenAPI·실패/skip/warning0·태스크/전체 검토 C0I0M0·미해결0이다. 기존494실행·이전44증거와 전체 검사 시작 이후 신규2파일 bytes가 일치한다. + +Sitesv137 public/native succeeded(2026-10-26T07:01:19.519682+00:00), source `da4586898ad3e75390f6ecb92b2adbfa71886458`, saved `appgprj_6ac612dd42708191a7a0c8377be64d48~appgver_c293e6fbc7a881918170a124167757d8`, deployment `appgdep_6adefaa257988191aa270d65bbdbc4af`다. 이전v136 정확 source/상대 경로 rewrite와 변경7문서 정확 사본(875b647)을 보존했다. HTML122/로컬대상3,746/공개텍스트380·비공개60+12항목 제외, 새브라우저QA/사용자탭 이동0이다. 이 게시 이후 최종 인계는 저장소 원본을 따른다. + +자체QA1/전용 익명volume1과 현재 SDD만 정리하고25598포트 닫힘을 확인했다. 운영 자원/앱/기본worker/main 변경·실제 제공자 호출0이다. 두 판단과 비용을 명세·검증 기록·PR에 남겼다. 다음은 실제 최종 생성·영속 저장/API/UI·의미 품질·개발 적용이며 순수 조합을 앱 완성으로 세지 않는다. PR 첨부는 대화 첨부100개 한도로 실패했으나 URL/기록은 보존한다. 원격 CI 보고와 로컬 전체 검증은 구분한다. + +## 검증 완료 · 질문별 브리핑 조합 · 2026-10-26 + +[조합 계약](../changes/question-briefing-composition.md)·[검증 범위](../research/question-briefing-composition-validation.md). 신규97개·새 전체9,886개(Python9,066+JS820)·정적/타입/빌드/OpenAPI·실패/skip/warning0, 독립 태스크/전체 검토 C0I0M0·미해결0이다. 기존494실행·이전44증거는 유지했다. 현재 원문·모든 영수증을 재검증하고 문장의 전이 불확실성·출처별 미반영 coverage·직접 근거 날짜를 보존한다. 모든 결과는 미검토이며 의미·진실/독립 사실검증을 인증하지 않는다. + +이번 구현은 순수 브리핑 제안 조합·전체 재구성 검사다. 실제 최종 생성·영속 저장/API/UI·개발 적용은 후속이며 앱/기본 worker/main/운영 자원 변경과 실제 제공자 호출0이다. 전체 검사는 전용 로컬 합성 QA만 사용했다. Draft PR·Sites와 자체 QA 정리 최종 인계는 아래에 기록한다. + ## 검증 완료 · 질문별 자동 분석·검증 결과 수집 · 2026-10-26 [계약](../changes/question-plan-runner.md)·[검증](../research/question-plan-runner-validation.md). 한 실행에서 기존 작업 완료를 기다리고 다음 자료를 진행한다. 완료 결과를 현재 세션/원문·canonical 영수증으로 다시 검증하며 부분 결과 없이 unreviewed 묶음으로 반환한다. 실행dca3341·신규73/기존queue5 포함 집중78·새 전체9,789개(Python8,969+JS820)·정적/타입/빌드/OpenAPI·실패/skip/warning0·태스크/전체 독립 검토 C0I0M0다. 기존492실행/이전49증거 보존, 실제 제공자/운영 변경0·앱/기본worker/main 유지다. 프로세스와 독립된 부모 복구·브리핑 합성/저장/API/UI·실제 의미/부하·개발 적용은 후속이며 PR/Sites·자체QA 정리는 최종 인계를 따른다. diff --git a/docs/planning/tasks.md b/docs/planning/tasks.md index 4fa4b46..37b43f7 100644 --- a/docs/planning/tasks.md +++ b/docs/planning/tasks.md @@ -1,3 +1,11 @@ +## 진행 · 질문별 브리핑 조합 · 2026-10-26 + +- [x] [미검토·원문 재검증·불확실성·coverage 조합 계약](../changes/question-briefing-composition.md) 고정. +- [x] 신규2파일 TDD·집중97개·독립 태스크/전체 검토 C0I0M0. +- [x] 새 전체9,886개/정적/타입/빌드/OpenAPI·기존494실행/이전44증거 보존. +- [x] privacy·문서·Draft PR115/Sitesv137·이전v136/7문서 사본·자체QA/volume1 정리(최종 인계). +- [ ] 실제 최종 생성·저장/API/UI·의미 인수·개발 적용(후속). + ## 진행 · 질문별 자동 분석·검증 결과 수집 · 2026-10-26 - [x] [boundedwait·현재 권한/원문·완료 영수증 수집 계약](../changes/question-plan-runner.md) 고정. diff --git a/docs/research/question-briefing-composition-validation.md b/docs/research/question-briefing-composition-validation.md new file mode 100644 index 0000000..7384cd6 --- /dev/null +++ b/docs/research/question-briefing-composition-validation.md @@ -0,0 +1,26 @@ +# 질문별 브리핑 조합 검증 + +2026-10-26. [명세](../changes/question-briefing-composition.md)·[진행](../planning/progress.md). + +신규2파일만 구현했다. 원래 분석 영수증의 명제·조건·의존 관계를 그대로 보존하고 요약·비교·타임라인 문장의 참조를 현재 원문에서 다시 검증한다. 불확실성은 참조와 그 전이 의존 관계 전체에서 결정하며 자료의 서술을 확인된 사실로 승격하지 않는다. 직접 참조한 명제의 supporting span에 정확한 ISO 날짜가 있어야 시간축 날짜를 허용한다. + +## 검증 + +- RED: 새 모듈 부재1실패. 최종 신규 집중97개 통과(실패/skip/warning0), Ruff·format·mypy 통과. +- 새 전체 9,886개(Python 9,066+JS 820) 통과, 실패/skip/warning0. Python/JS lint·형식·타입·웹 build·OpenAPI 검사 통과. 전용 로컬 합성 PostgreSQL만 사용했다. +- 독립 태스크/전체 코드 검토 C0I0M0, 미해결0. 런타임 커밋 `ac7a9146cff27c48069075942ee354dc62a89496`와 동일 실행 bytes로 전체 검사를 수행했다. +- 기존494실행·이전44증거를 hash로 보존했다. 기존 모듈/fixture/exports·의존성·API/UI·DB/migration·provider/prompt/workflow는 바꾸지 않았다. +- 복수 출처의 동일 로컬 p1 분리, 전이 의존성/불확실성, 부분·미반영 coverage, 모든 출처 영수증 재검증, 충분한 근거 없음, 원래 타입/누락/추가/unchecked copy 변조, 날짜 위치, 전체 재구성/JSON 왕복을 합성 검사했다. + +## 범위·한계 + +순수 미검토 조합 계약이다. 실제 최종 모델 요청·그 모델의 의미 품질·영속 저장/API/UI 연결·개발 적용은 아직 인수하지 않았다. 자유 문장과 날짜의 의미는 참조 검사만으로 검증되지 않으며 coverage는 원래 모델이 선택한 명제의 범위다. 모든 실제 관련 사실을 빠짐없이 찾았다는 인증이 아니다. 입력 영수증은 별도 인증 서명이나 현재 권한 승인으로 쓰지 않는다. 제안과 파생 문장을 함께 일관되게 교체하면 다른 유효한 미검토 제안이 될 수 있다. 재구성 검사는 출처·구조 일관성 검사이며 기록의 인증/불변성은 향후 저장 계층에서 보호해야 한다. 하위 renderer는 문자열을 안전하게 표시해야 한다. + +같은 질문·원문·영수증·제안에서 결과가 결정적이며 저장 후보는 전체 재구성 비교로 변조를 거부한다. 새 provider/client·DBOS 등록·공개 게시·운영 변경·앱/기본 worker/main 활성화는0이다. 외부 통신 없는 라이브러리이며 실제 비공개 자료를 테스트에 복제하지 않았다. + +## 판단과 비용 + +1. 원래 명제·조건·미반영 coverage를 함께 보존하고 모든 자유 문장을 미검토로 유지했다. 인용은 의미 인증이 아니므로 실제 모델 품질 검증이 남는다. +2. 역사 Canvas 버전을 재사용하지 않는 별도 조합 profile을 사용했다. 구형 화면에 즉시 연결되지 않으며 최종 생성·저장·표시 연결이 남는다. + +PR·Sites 및 QA 정리의 최종 인계는 이후 저장소 진행 기록을 따른다. Sites는 별도 시점의 정확 사본이며 저장소가 원본이다. diff --git a/packages/agents/src/issuedigest_agents/question_briefing.py b/packages/agents/src/issuedigest_agents/question_briefing.py new file mode 100644 index 0000000..f45ebec --- /dev/null +++ b/packages/agents/src/issuedigest_agents/question_briefing.py @@ -0,0 +1,324 @@ +"""Pure unreviewed briefing composition; references never certify prose meaning. + +No transport, persistence, application registration, or historical Canvas conversion. +Texts are untrusted plain text; downstream renderers must handle them safely. +""" + +import re +from datetime import date +from hashlib import sha256 +from typing import Annotated, Any, Literal, Self + +from issuedigest_domain.sources import Sha256 +from pydantic import UUID4, Field, field_validator, model_validator + +from .guarded_question_evidence_v2 import ( + GuardedQuestionEvidenceGenerationV2, + validate_guarded_question_evidence_v2, +) +from .numeric_evidence import Source, _StrictModel +from .numeric_selection_evaluation import _read +from .question_evidence_propositions import ( + EvidenceProposition, + PropositionId, + QuestionCoverage, + _guard_actual, + _original, + _source, +) + + +class QuestionBriefingError(ValueError): + """Fixed whole-operation failure, without sensitive input or chained errors.""" + + def __init__(self) -> None: + super().__init__("Invalid question briefing") + + +class QuestionBriefingReference(_StrictModel): + snapshot_id: UUID4 + proposition_id: PropositionId + + +References = Annotated[tuple[QuestionBriefingReference, ...], Field(min_length=1, max_length=8)] +Text = Annotated[str, Field(min_length=1, max_length=400)] +Title = Annotated[str, Field(min_length=1, max_length=100)] +Message = Annotated[str, Field(min_length=1, max_length=500)] + + +class QuestionBriefingClaimProposal(_StrictModel): + text: Text + references: References + event_date: date | None + + @field_validator("text") + @classmethod + def nonblank_text(cls, value: str) -> str: + return _nonblank(value) + + @model_validator(mode="after") + def unique_references(self) -> Self: + if len(set(self.references)) != len(self.references): + raise ValueError("Duplicate references") + return self + + +class QuestionBriefingModuleProposal(_StrictModel): + kind: Literal["summary", "comparison", "timeline"] + title: Title + claims: Annotated[tuple[QuestionBriefingClaimProposal, ...], Field(min_length=1, max_length=4)] + + @field_validator("title") + @classmethod + def nonblank_title(cls, value: str) -> str: + return _nonblank(value) + + @model_validator(mode="after") + def module_shape(self) -> Self: + if self.kind == "comparison" and len(self.claims) < 2: + raise ValueError("Comparison needs multiple claims") + if any( + (claim.event_date is not None) != (self.kind == "timeline") for claim in self.claims + ): + raise ValueError("Dates belong only to timeline claims") + return self + + +class QuestionBriefingProposal(_StrictModel): + outcome: Literal["answer", "insufficient_evidence"] + message: Message | None + modules: Annotated[tuple[QuestionBriefingModuleProposal, ...], Field(max_length=3)] + + @field_validator("message") + @classmethod + def nonblank_message(cls, value: str | None) -> str | None: + return None if value is None else _nonblank(value) + + @model_validator(mode="after") + def outcome_shape(self) -> Self: + if self.outcome == "answer": + if self.message is not None or not self.modules or self.modules[0].kind != "summary": + raise ValueError("Answer requires first summary and no message") + if any(module.kind == "summary" for module in self.modules[1:]): + raise ValueError("Only the first module is a summary") + elif self.modules or self.message is None: + raise ValueError("Insufficient evidence needs message and no modules") + return self + + +class QuestionBriefingClaim(QuestionBriefingClaimProposal): + epistemic_status: Literal["source_statement", "uncertain"] + + +class QuestionBriefingModule(_StrictModel): + kind: Literal["summary", "comparison", "timeline"] + title: Title + claims: Annotated[tuple[QuestionBriefingClaim, ...], Field(min_length=1, max_length=4)] + + +class QuestionBriefingSource(_StrictModel): + snapshot_id: UUID4 + source_sha256: Sha256 + receipt: GuardedQuestionEvidenceGenerationV2 + + +class QuestionBriefingCoverage(QuestionCoverage): + snapshot_id: UUID4 + closure_proposition_ids: tuple[PropositionId, ...] + used_proposition_ids: tuple[PropositionId, ...] + uncovered_linked_proposition_ids: tuple[PropositionId, ...] + + +class QuestionBriefing(_StrictModel): + """Current source-bound composition, with original receipts and proposal intact.""" + + profile: Literal["question-briefing-v1"] + status: Literal["unreviewed"] + question_sha256: Sha256 + workspace_id: UUID4 + sources: Annotated[tuple[QuestionBriefingSource, ...], Field(min_length=1, max_length=5)] + outcome: Literal["answer", "insufficient_evidence"] + message: Message | None + proposal: QuestionBriefingProposal + modules: Annotated[tuple[QuestionBriefingModule, ...], Field(max_length=3)] + coverage: Annotated[tuple[QuestionBriefingCoverage, ...], Field(min_length=1, max_length=5)] + + +def _nonblank(value: str) -> str: + if not value.strip(): + raise ValueError("Blank text") + value.encode("utf-8") + return value + + +def _closure(propositions: dict[str, EvidenceProposition], ids: tuple[str, ...]) -> set[str]: + # Existing receipt validator has already checked all local IDs and graph cycles. + found: set[str] = set() + pending = list(ids) + while pending: + identifier = pending.pop() + if identifier not in found: + found.add(identifier) + pending.extend(propositions[identifier].depends_on_ids) + return found + + +def _build( + question: str, + sources: tuple[Source, ...], + receipts: tuple[GuardedQuestionEvidenceGenerationV2, ...], + proposal: QuestionBriefingProposal | dict[str, Any], +) -> QuestionBriefing: + if type(question) is not str or not 1 <= len(question) <= 1500: + raise ValueError("Invalid question") + _nonblank(question) + _guard_actual(sources, tuple[Source, ...]) + _guard_actual(receipts, tuple[GuardedQuestionEvidenceGenerationV2, ...]) + if not 1 <= len(sources) <= 5 or len(receipts) != len(sources): + raise ValueError("Invalid source count") + _original(proposal, QuestionBriefingProposal) + submitted = _read(QuestionBriefingProposal, proposal) + bound: list[QuestionBriefingSource] = [] + snapshots: set[Any] = set() + versions: set[Any] = set() + citations: set[Any] = set() + workspace = sources[0][1].document.workspace_id + for source, receipt in zip(sources, receipts, strict=True): + sid, bundle, presentation = source + source_hash, _ = _source(source) + version = bundle.version.version_id + identifiers = {citation.citation_id for citation in bundle.citations} + if ( + sid in snapshots + or version in versions + or citations & identifiers + or bundle.document.workspace_id != workspace + or presentation.workspace_id != workspace + ): + raise ValueError("Duplicate or inconsistent sources") + snapshots.add(sid) + versions.add(version) + citations.update(identifiers) + checked = validate_guarded_question_evidence_v2(source, question, receipt) + bound.append( + QuestionBriefingSource(snapshot_id=sid, source_sha256=source_hash, receipt=checked) + ) + # Snapshot keys keep reused source-local proposition IDs separate. + indexes = { + item.snapshot_id: {p.id: p for p in item.receipt.evidence.propositions} for item in bound + } + linked = { + item.snapshot_id: set(item.receipt.evidence.coverage.linked_proposition_ids) + for item in bound + } + allowed = { + sid: _closure(indexes[sid], tuple(identifiers)) for sid, identifiers in linked.items() + } + used: dict[Any, set[str]] = {item.snapshot_id: set() for item in bound} + has_link = False + modules: list[QuestionBriefingModule] = [] + for module in submitted.modules: + claims: list[QuestionBriefingClaim] = [] + for claim in module.claims: + certain = True + date_anchored = claim.event_date is None + for reference in claim.references: + sid, pid = reference.snapshot_id, reference.proposition_id + if sid not in indexes or pid not in allowed[sid]: + raise ValueError("Reference outside linked source graph") + has_link = has_link or pid in linked[sid] + closure = _closure(indexes[sid], (pid,)) + used[sid].update(closure) + certain = certain and all( + indexes[sid][identifier].epistemic_status == "source_statement" + for identifier in closure + ) + if claim.event_date is not None: + literal = claim.event_date.isoformat() + date_anchored = date_anchored or any( + re.search(r"(? QuestionBriefing: + """Compose only current validated source receipts; no semantic approval.""" + try: + return _build(question, sources, receipts, proposal) + except Exception: + pass + # Outside the handler: even __context__ must not expose sanitized internals. + raise QuestionBriefingError() + + +def validate_question_briefing( + question: str, + sources: tuple[Source, ...], + receipts: tuple[GuardedQuestionEvidenceGenerationV2, ...], + result: QuestionBriefing | dict[str, Any], +) -> QuestionBriefing: + """Rebuild the entire saved composition and reject any divergence, without repair.""" + try: + _original(result, QuestionBriefing) + checked = _read(QuestionBriefing, result) + rebuilt = _build(question, sources, receipts, checked.proposal) + if checked != rebuilt: + raise ValueError("Changed briefing") + return rebuilt + except Exception: + pass + raise QuestionBriefingError() diff --git a/packages/agents/tests/test_question_briefing.py b/packages/agents/tests/test_question_briefing.py new file mode 100644 index 0000000..2419c16 --- /dev/null +++ b/packages/agents/tests/test_question_briefing.py @@ -0,0 +1,692 @@ +"""Synthetic composition contracts; no provider, storage, or application activation.""" + +import importlib +import importlib.util +import json +import sys +from copy import deepcopy +from datetime import date, datetime +from hashlib import sha256 +from uuid import uuid4 + +import pytest +from issuedigest_domain import EvidenceBundle +from pydantic import BaseModel +from test_numeric_evidence import document +from test_question_evidence_propositions import QUESTION, TEXT, packet, proposition, ref + + +@pytest.fixture(scope="module", autouse=True) +def restore_imports(): + package = importlib.import_module("issuedigest_agents") + before, attributes, path = set(sys.modules), vars(package).copy(), sys.path.copy() + yield + for name in set(sys.modules) - before: + if name.startswith("issuedigest_agents."): + sys.modules.pop(name, None) + for name in set(vars(package)) - attributes.keys(): + delattr(package, name) + for name, value in attributes.items(): + setattr(package, name, value) + sys.path[:] = path + + +def api(): + name = "issuedigest_agents.question_briefing" + assert importlib.util.find_spec(name) is not None, "Missing question briefing composition" + return importlib.import_module(name) + + +def source(text=TEXT, *extra): + sid, bundle, presentation = document(text, *extra) + raw = bundle.model_dump_json() + for identifier in (bundle.document.document_id, bundle.version.version_id): + raw = raw.replace(str(identifier), str(uuid4())) + return sid, EvidenceBundle.model_validate_json(raw), presentation + + +def receipt(src, data=None): + guarded = importlib.import_module("issuedigest_agents.guarded_question_evidence_v2") + evidence = importlib.import_module("issuedigest_agents.question_evidence_propositions") + prompt, _ = guarded._preflight(src, QUESTION) + return guarded.GuardedQuestionEvidenceGenerationV2( + profile="guarded-question-evidence-v2", + transport_profile="guarded-responses-tool-v1", + input_format="question-evidence-source-v2", + status="unreviewed", + evidence=evidence.build_question_evidence_propositions(src, QUESTION, data or packet(src)), + prompt_sha256=sha256(prompt.encode()).hexdigest(), + instructions_sha256=sha256(guarded.GUARDED_QUESTION_V2_INSTRUCTIONS.encode()).hexdigest(), + usage=guarded.KnownUsage(input_tokens=10, output_tokens=5, requests=1), + ) + + +def reference(src, identifier="p1"): + return dict(snapshot_id=str(src[0]), proposition_id=identifier) + + +def claim(*refs, text="자료에 따르면 조건이 있다.", event_date=None): + return dict(text=text, references=list(refs), event_date=event_date) + + +def proposal(*claims): + return dict( + outcome="answer", + message=None, + modules=[dict(kind="summary", title="요약", claims=list(claims))], + ) + + +def setup(): + src = source() + return api(), src, receipt(src), proposal(claim(reference(src))) + + +def reject(operation, *args): + mod = api() + with pytest.raises(mod.QuestionBriefingError) as caught: + operation(*args) + assert str(caught.value) == "Invalid question briefing" + assert caught.value.__cause__ is None and caught.value.__context__ is None + + +def test_multisource_preserves_full_receipts_and_derives_dependency_uncertainty_coverage(): + mod = api() + first, second = source(), source(TEXT + " 2026-10-26") + data = packet(first) + data["propositions"] += [ + proposition(first, "p2", depends_on_ids=["p3"]), + proposition(first, "p3", epistemic_status="uncertain", subject_refs=[]), + proposition(first, "p4"), + ] + data["propositions"][0]["depends_on_ids"] = ["p2"] + data["coverage"] = dict(linked_proposition_ids=["p4", "p1"], unresolved_reason="ambiguous") + receipts = (receipt(first, data), receipt(second)) + submitted = proposal(claim(reference(first)), claim(reference(second))) + got = mod.build_question_briefing(QUESTION, (first, second), receipts, submitted) + assert got.profile == "question-briefing-v1" and got.status == "unreviewed" + assert got.question_sha256 == sha256(QUESTION.encode()).hexdigest() + assert got.workspace_id == first[1].document.workspace_id + assert tuple(item.snapshot_id for item in got.sources) == (first[0], second[0]) + assert tuple(item.receipt for item in got.sources) == receipts + assert [c.epistemic_status for c in got.modules[0].claims] == ["uncertain", "source_statement"] + coverage = got.coverage[0] + assert coverage.snapshot_id == first[0] + assert coverage.linked_proposition_ids == ("p1", "p4") + assert coverage.closure_proposition_ids == ("p1", "p2", "p3", "p4") + assert coverage.used_proposition_ids == ("p1", "p2", "p3") + assert coverage.uncovered_linked_proposition_ids == ("p4",) + assert coverage.unresolved_reason == "ambiguous" + assert got.proposal.model_dump(mode="json") == submitted + assert got == mod.build_question_briefing(QUESTION, (first, second), receipts, submitted) + for saved in (got, json.loads(got.model_dump_json())): + checked = mod.validate_question_briefing(QUESTION, (first, second), receipts, saved) + assert checked == got and checked is not got + + +def test_insufficient_preserves_every_source_and_unrepresented_link(): + mod, src, rec, _ = setup() + empty = source() + no_link = dict( + propositions=[], + coverage=dict(linked_proposition_ids=[], unresolved_reason="no_linked_evidence"), + ) + receipts = (rec, receipt(empty, no_link)) + submitted = dict(outcome="insufficient_evidence", message="근거가 부족합니다.", modules=[]) + got = mod.build_question_briefing(QUESTION, (src, empty), receipts, submitted) + assert got.modules == () and got.message == submitted["message"] + assert got.coverage[0].used_proposition_ids == () + assert got.coverage[0].uncovered_linked_proposition_ids == ("p1",) + assert got.coverage[1].unresolved_reason == "no_linked_evidence" + assert tuple(item.receipt for item in got.sources) == receipts + assert mod.validate_question_briefing(QUESTION, (src, empty), receipts, got) == got + + +@pytest.mark.parametrize( + "kind", + ["unrelated", "unknown", "wrong_source", "duplicate", "dependency_only", "no_link", "cycle"], +) +def test_references_require_current_local_link_or_dependency_and_some_direct_link(kind): + mod, src, rec, submitted = setup() + data = packet(src) + data["propositions"] += [proposition(src, "p2"), proposition(src, "p3")] + data["propositions"][0]["depends_on_ids"] = ["p2"] + if kind == "cycle": + data["propositions"][1]["depends_on_ids"] = ["p1"] + # Corrupt an otherwise validated receipt: builder must recheck the whole graph. + rec = receipt(src) + evidence = importlib.import_module("issuedigest_agents.question_evidence_propositions") + bad = evidence.QuestionEvidenceInput.model_validate_json(json.dumps(data), strict=True) + rec = rec.model_copy( + update={"evidence": rec.evidence.model_copy(update={"propositions": bad.propositions})} + ) + else: + if kind == "no_link": + data["coverage"] = dict(linked_proposition_ids=[], unresolved_reason="insufficient") + rec = receipt(src, data) + target = submitted["modules"][0]["claims"][0]["references"] + if kind == "unrelated": + target[0]["proposition_id"] = "p3" + elif kind in ("dependency_only", "no_link"): + target[0]["proposition_id"] = "p2" + elif kind == "unknown": + target[0]["proposition_id"] = "p24" + elif kind == "wrong_source": + target[0]["snapshot_id"] = str(uuid4()) + elif kind == "duplicate": + target.append(deepcopy(target[0])) + reject(mod.build_question_briefing, QUESTION, (src,), (rec,), submitted) + + +def test_linked_plus_dependency_reference_is_allowed_without_flattening(): + mod, src, _, submitted = setup() + data = packet(src) + data["propositions"].append(proposition(src, "p2")) + data["propositions"][0]["depends_on_ids"] = ["p2"] + submitted["modules"][0]["claims"].append(claim(reference(src, "p2"))) + got = mod.build_question_briefing(QUESTION, (src,), (receipt(src, data),), submitted) + assert len(got.sources[0].receipt.evidence.propositions[0].facets) == 9 + assert got.coverage[0].used_proposition_ids == ("p1", "p2") + + +@pytest.mark.parametrize( + "kind", + [ + "exact", + "metadata", + "unrelated_quote", + "dependency", + "partial", + "invalid_date", + "non_timeline", + ], +) +def test_dates_only_anchor_to_direct_proposition_support(kind): + mod = api() + text = TEXT + (" 2026-10-26" if kind in ("exact", "partial", "non_timeline") else "") + if kind == "partial": + text += "0" + src = source(text, "2026-10-26") + data = packet(src) + if kind == "metadata": + src = (src[0], src[1], src[2].model_copy(update={"bill_number": "2026-10-26"})) + if kind == "dependency": + data["propositions"].append( + proposition( + src, + "p2", + supporting_refs=[ref(src, "2026-10-26", 1)], + subject_refs=[], + predicate_refs=[], + epistemic_status="uncertain", + ) + ) + data["propositions"][0]["depends_on_ids"] = ["p2"] + rec = receipt(src, data) + submitted = proposal(claim(reference(src))) + dated = claim( + reference(src), event_date="2026-10-26" if kind != "invalid_date" else "2026-02-30" + ) + if kind == "non_timeline": + submitted["modules"][0]["claims"] = [dated] + else: + submitted["modules"].append(dict(kind="timeline", title="시간축", claims=[dated])) + if kind == "exact": + got = mod.build_question_briefing(QUESTION, (src,), (rec,), submitted) + assert got.modules[1].claims[0].event_date == date(2026, 10, 26) + assert ( + mod.validate_question_briefing( + QUESTION, (src,), (rec,), json.loads(got.model_dump_json()) + ) + == got + ) + else: + reject(mod.build_question_briefing, QUESTION, (src,), (rec,), submitted) + + +@pytest.mark.parametrize( + "kind", + [ + "question", + "question_bool", + "question_long", + "blank", + "source_metadata", + "source_quote", + "prompt", + "instructions", + "usage", + "receipt_order", + "receipt_missing", + "raw_receipt", + "sources_list", + "empty", + "six", + "snapshot_duplicate", + "version_duplicate", + "citation_duplicate", + "workspace", + ], +) +def test_all_sources_receipts_and_original_question_are_rechecked(kind): + mod, src, rec, submitted = setup() + sources, receipts, question = (src,), (rec,), QUESTION + if kind == "question": + question += " " + elif kind == "question_bool": + question = True + elif kind == "question_long": + question = "x" * 1501 + elif kind == "blank": + question = " " + elif kind in ("source_metadata", "source_quote"): + target, field = ( + (src[2], "title") + if kind == "source_metadata" + else (src[1].citations[0].selector, "quote") + ) + object.__setattr__(target, field, "sensitive changed source") + elif kind in ("prompt", "instructions"): + receipts = (rec.model_copy(update={kind + "_sha256": "0" * 64}),) + elif kind == "usage": + receipts = ( + rec.model_copy(update={"usage": rec.usage.model_copy(update={"requests": True})}), + ) + elif kind == "receipt_missing": + receipts = () + elif kind == "raw_receipt": + receipts = (rec.model_dump(mode="json"),) + elif kind == "sources_list": + sources = [src] + elif kind == "empty": + sources, receipts = (), () + elif kind == "six": + sources, receipts = (src,) * 6, (rec,) * 6 + else: + other = source() + if kind == "workspace": + wid = uuid4() + other = ( + other[0], + other[1].model_copy( + update={"document": other[1].document.model_copy(update={"workspace_id": wid})} + ), + other[2].model_copy(update={"workspace_id": wid}), + ) + if kind == "snapshot_duplicate": + other = (src[0], other[1], other[2].model_copy(update={"snapshot_id": src[0]})) + if kind in ("version_duplicate", "citation_duplicate"): + raw = other[1].model_dump_json() + old, new = ( + (other[1].version.version_id, src[1].version.version_id) + if kind == "version_duplicate" + else (other[1].citations[0].citation_id, src[1].citations[0].citation_id) + ) + other = ( + other[0], + EvidenceBundle.model_validate_json(raw.replace(str(old), str(new))), + other[2], + ) + sources, receipts = (src, other), (rec, receipt(other)) + if kind == "receipt_order": + receipts = tuple(reversed(receipts)) + reject(mod.build_question_briefing, question, sources, receipts, submitted) + + +@pytest.mark.parametrize( + "kind", + [ + "category", + "extra", + "missing_message", + "missing_date", + "blank_title", + "blank_text", + "long_text", + "long_title", + "zero_claim", + "five_claims", + "four_modules", + "comparison_one", + "timeline_null", + "late_summary", + "first_comparison", + "answer_message", + "insufficient_modules", + "insufficient_no_message", + "insufficient_long_message", + "bad_kind", + "bool_text", + "tuple_raw", + "string_subclass", + "model_copy", + "model_construct", + "datetime", + ], +) +def test_proposal_strict_shape_and_module_semantics(kind): + mod, src, rec, submitted = setup() + module, item = submitted["modules"][0], submitted["modules"][0]["claims"][0] + if kind == "category": + item["category"] = "documented" + elif kind == "extra": + submitted["action"] = "execute" + elif kind == "missing_message": + del submitted["message"] + elif kind == "missing_date": + del item["event_date"] + elif kind == "blank_title": + module["title"] = " " + elif kind == "blank_text": + item["text"] = "\t" + elif kind == "long_text": + item["text"] = "x" * 401 + elif kind == "long_title": + module["title"] = "x" * 101 + elif kind == "zero_claim": + module["claims"] = [] + elif kind == "five_claims": + module["claims"] *= 5 + elif kind == "four_modules": + submitted["modules"] *= 4 + elif kind == "comparison_one": + submitted["modules"].append(dict(module, kind="comparison")) + elif kind == "timeline_null": + submitted["modules"].append(dict(module, kind="timeline")) + elif kind == "late_summary": + submitted["modules"].append(deepcopy(module)) + elif kind == "first_comparison": + module["kind"] = "comparison" + module["claims"] *= 2 + elif kind == "answer_message": + submitted["message"] = "unexpected" + elif kind == "insufficient_modules": + submitted["outcome"] = "insufficient_evidence" + submitted["message"] = "missing" + elif kind in ("insufficient_no_message", "insufficient_long_message"): + submitted = dict( + outcome="insufficient_evidence", + message=None if kind == "insufficient_no_message" else "x" * 501, + modules=[], + ) + elif kind == "bad_kind": + module["kind"] = "html" + elif kind == "bool_text": + item["text"] = True + elif kind == "tuple_raw": + item["references"] = tuple(item["references"]) + elif kind == "string_subclass": + + class Text(str): + pass + + item["text"] = Text(item["text"]) + elif kind in ("model_copy", "model_construct", "datetime"): + actual = mod.QuestionBriefingProposal.model_validate_json( + json.dumps(submitted), strict=True + ) + if kind == "model_copy": + submitted = actual.model_copy(update={"modules": list(actual.modules)}) + elif kind == "model_construct": + submitted = mod.QuestionBriefingProposal.model_construct( + **dict(actual.__dict__, message=False) + ) + else: + changed = ( + actual.modules[0] + .claims[0] + .model_copy(update={"event_date": datetime(2026, 10, 26)}) + ) + submitted = actual.model_copy( + update={"modules": (actual.modules[0].model_copy(update={"claims": (changed,)}),)} + ) + reject(mod.build_question_briefing, QUESTION, (src,), (rec,), submitted) + + +def paths(value, prefix=()): + if type(value) is dict: + for key, child in value.items(): + yield (*prefix, key) + yield from paths(child, (*prefix, key)) + elif type(value) is list: + for i, child in enumerate(value): + yield from paths(child, (*prefix, i)) + + +@pytest.mark.parametrize("actual", [False, True]) +def test_every_saved_field_required_even_null_or_empty(actual): + mod, src, rec, submitted = setup() + got = mod.build_question_briefing(QUESTION, (src,), (rec,), submitted) + for path in paths(got.model_dump(mode="json")): + changed = deepcopy(got) if actual else got.model_dump(mode="json") + parent = changed + for key in path[:-1]: + parent = getattr(parent, key) if isinstance(parent, BaseModel) else parent[key] + del (parent.__dict__ if isinstance(parent, BaseModel) else parent)[path[-1]] + reject(mod.validate_question_briefing, QUESTION, (src,), (rec,), changed) + + +@pytest.mark.parametrize( + "kind", + [ + "profile", + "status", + "question_hash", + "workspace", + "text", + "category", + "date", + "coverage", + "receipt", + "proposal", + "extra", + "order", + ], +) +def test_rebuild_compares_every_saved_value(kind): + mod, src, rec, submitted = setup() + submitted["modules"][0]["claims"].append(claim(reference(src), text="두 번째 미검토 문장")) + got = mod.build_question_briefing(QUESTION, (src,), (rec,), submitted) + raw = got.model_dump(mode="json") + if kind == "profile": + raw["profile"] = "research-v10" + elif kind == "status": + raw["status"] = "reviewed" + elif kind == "question_hash": + raw["question_sha256"] = "0" * 64 + elif kind == "workspace": + raw["workspace_id"] = str(uuid4()) + elif kind == "text": + raw["modules"][0]["claims"][0]["text"] = "forged" + elif kind == "category": + raw["modules"][0]["claims"][0]["epistemic_status"] = "uncertain" + elif kind == "date": + raw["modules"][0]["claims"][0]["event_date"] = "2026-10-26" + elif kind == "coverage": + raw["coverage"][0]["used_proposition_ids"] = [] + elif kind == "receipt": + raw["sources"][0]["receipt"]["usage"]["input_tokens"] += 1 + elif kind == "proposal": + raw["proposal"]["modules"][0]["claims"][0]["text"] = "forged" + elif kind == "extra": + raw["untrusted"] = "sensitive" + else: + raw["modules"][0]["claims"].reverse() + reject(mod.validate_question_briefing, QUESTION, (src,), (rec,), raw) + + +def test_plain_untrusted_text_and_typed_proposal_work_without_network_or_app_imports(monkeypatch): + import socket + + before = set(sys.modules) + monkeypatch.setattr(socket.socket, "connect", lambda *a: pytest.fail("Unexpected network")) + mod, src, rec, submitted = setup() + submitted["modules"][0]["claims"][0]["text"] = "" + actual = mod.QuestionBriefingProposal.model_validate_json(json.dumps(submitted), strict=True) + got = mod.build_question_briefing(QUESTION, (src,), (rec,), actual) + assert got.modules[0].claims[0].text == "" + assert got.status == "unreviewed" + assert not any( + name.startswith(("issuedigest_api", "issuedigest_worker", "dbos")) + for name in set(sys.modules) - before + ) + + +@pytest.mark.parametrize("kind", ["prompt", "instructions", "usage", "question", "metadata"]) +def test_unreferenced_source_receipt_is_never_skipped(kind): + mod, first, first_receipt, submitted = setup() + second = source() + second_receipt = receipt(second) + if kind in ("prompt", "instructions"): + second_receipt = second_receipt.model_copy(update={kind + "_sha256": "0" * 64}) + elif kind == "usage": + second_receipt = second_receipt.model_copy( + update={"usage": second_receipt.usage.model_copy(update={"output_tokens": 4001})} + ) + elif kind == "question": + second_receipt = second_receipt.model_copy( + update={ + "evidence": second_receipt.evidence.model_copy(update={"question_sha256": "0" * 64}) + } + ) + else: + second = (second[0], second[1], second[2].model_copy(update={"title": "changed"})) + reject( + mod.build_question_briefing, + QUESTION, + (first, second), + (first_receipt, second_receipt), + submitted, + ) + + +@pytest.mark.parametrize( + "kind", + [ + "source", + "receipt", + "proposal", + "saved", + "scalar", + "container", + "field_key", + "extra_model", + "raw_dict", + "raw_list", + "raw_int", + "raw_uuid", + "raw_date", + ], +) +def test_original_subclasses_and_unchecked_copies_are_rejected_before_normalization(kind): + mod, src, rec, submitted = setup() + sources, receipts = (src,), (rec,) + actual = mod.QuestionBriefingProposal.model_validate_json(json.dumps(submitted), strict=True) + if kind == "source": + + class SourceTuple(tuple): + pass + + sources = (SourceTuple(src),) + elif kind == "receipt": + + class ChildReceipt(type(rec)): + pass + + receipts = (ChildReceipt(**rec.__dict__),) + elif kind == "proposal": + + class ChildProposal(mod.QuestionBriefingProposal): + pass + + submitted = ChildProposal(**actual.__dict__) + elif kind == "saved": + got = mod.build_question_briefing(QUESTION, sources, receipts, actual) + + class ChildBriefing(mod.QuestionBriefing): + pass + + reject( + mod.validate_question_briefing, + QUESTION, + sources, + receipts, + ChildBriefing(**got.__dict__), + ) + return + elif kind in ("scalar", "field_key", "extra_model"): + + class TextSubclass(str): + pass + + submitted = deepcopy(actual) + item = submitted.modules[0].claims[0] + if kind == "scalar": + item.__dict__["text"] = TextSubclass(item.text) + elif kind == "field_key": + item.__dict__[TextSubclass("text")] = item.__dict__.pop("text") + else: + item.__dict__["extra"] = "sensitive" + elif kind == "container": + submitted = actual.model_copy(update={"modules": list(actual.modules)}) + elif kind == "raw_dict": + + class Object(dict): + pass + + submitted = Object(submitted) + elif kind == "raw_list": + + class Array(list): + pass + + submitted["modules"] = Array(submitted["modules"]) + elif kind == "raw_int": + + class Integer(int): + pass + + receipts = ( + rec.model_copy( + update={"usage": rec.usage.model_copy(update={"input_tokens": Integer(10)})} + ), + ) + elif kind == "raw_uuid": + submitted["modules"][0]["claims"][0]["references"][0]["snapshot_id"] = 1 + else: + submitted["modules"][0]["claims"][0]["event_date"] = 1792972800 + reject(mod.build_question_briefing, QUESTION, sources, receipts, submitted) + + +def test_five_sources_comparison_and_date_in_other_claim_support(): + mod = api() + sources = tuple(source(TEXT + " (2026-10-26).") for _ in range(5)) + receipts = tuple(receipt(src) for src in sources) + submitted = proposal(claim(*(reference(src) for src in sources))) + submitted["modules"] += [ + dict( + kind="comparison", title="비교", claims=[claim(reference(src)) for src in sources[:4]] + ), + dict( + kind="timeline", + title="시점", + claims=[claim(reference(sources[0]), event_date="2026-10-26")], + ), + ] + got = mod.build_question_briefing(QUESTION, sources, receipts, submitted) + assert len(got.sources) == 5 and len(got.modules[1].claims) == 4 + assert all(item.used_proposition_ids == ("p1",) for item in got.coverage) + assert got.modules[2].claims[0].event_date == date(2026, 10, 26) + assert mod.validate_question_briefing(QUESTION, sources, receipts, got) == got + + +def test_date_elsewhere_in_same_quote_is_not_support(): + mod = api() + src = source(TEXT + " 2026-10-26") + data = packet(src) + data["propositions"][0]["supporting_refs"] = [ref(src, TEXT)] + submitted = proposal(claim(reference(src))) + submitted["modules"].append( + dict(kind="timeline", title="날짜", claims=[claim(reference(src), event_date="2026-10-26")]) + ) + reject(mod.build_question_briefing, QUESTION, (src,), (receipt(src, data),), submitted)