페르소나 소스팩 동기화 정리
This commit is contained in:
parent
6a81ec596c
commit
e8e08935ed
10 changed files with 1126 additions and 283 deletions
222
apps/api/app/persona_read_model.py
Normal file
222
apps/api/app/persona_read_model.py
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
"""Browser-facing persona DTOs and deterministic mappers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .persona_repository import CatalogPersona, PersonaDraftRecord, PersonaReviewAction, PersonaReviewItem
|
||||
from .services.persona import PersonaCard
|
||||
|
||||
JSON_OBJECT_FIELD = {"additionalProperties": True}
|
||||
PersonaSourceKind = Literal["client_record", "textbook_guide", "mixed_notes"]
|
||||
|
||||
|
||||
class PersonaSummary(BaseModel):
|
||||
persona_id: str | None = None
|
||||
code: str
|
||||
version: int | None = None
|
||||
status: Literal["approved"] = "approved"
|
||||
display_name: str
|
||||
difficulty: str
|
||||
theory_target: list[str]
|
||||
demographics: dict[str, Any]
|
||||
presenting_summary: str
|
||||
voice_preset: str | None = None
|
||||
source: str = "database"
|
||||
degraded: bool = False
|
||||
|
||||
|
||||
class PersonaReviewSummary(BaseModel):
|
||||
persona_id: str
|
||||
code: str
|
||||
version: int
|
||||
status: Literal["draft", "review", "approved", "archived"]
|
||||
display_name: str
|
||||
difficulty: str
|
||||
theory_target: list[str]
|
||||
source_provenance: str
|
||||
is_synthetic: bool
|
||||
created_at: str | None = None
|
||||
approved_at: str | None = None
|
||||
|
||||
|
||||
class PersonaReviewDecisionRequest(BaseModel):
|
||||
action: PersonaReviewAction
|
||||
|
||||
|
||||
class PersonaRevisionRequest(BaseModel):
|
||||
submit_for_review: bool = False
|
||||
|
||||
|
||||
class PersonaDraftPayload(BaseModel):
|
||||
code: str = Field(min_length=1, max_length=24)
|
||||
display_name: str = Field(min_length=1, max_length=80)
|
||||
difficulty: Literal["easy", "moderate", "hard"]
|
||||
theory_target: list[str] = Field(default_factory=list)
|
||||
demographics: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD)
|
||||
presenting: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD)
|
||||
history: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD)
|
||||
big5: dict[str, float] = Field(default_factory=dict)
|
||||
resistance: dict[str, float] = Field(default_factory=dict)
|
||||
speech_style: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD)
|
||||
affect_baseline: dict[str, float] = Field(default_factory=dict)
|
||||
ccd: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD)
|
||||
dsm5_dimensional: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD)
|
||||
triggers: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD)
|
||||
source_provenance: str = Field(default="", max_length=240)
|
||||
is_synthetic: bool = True
|
||||
submit_for_review: bool = False
|
||||
|
||||
|
||||
class PersonaSourceDocumentRequest(BaseModel):
|
||||
filename: str = Field(min_length=1, max_length=240)
|
||||
source_kind: PersonaSourceKind = "mixed_notes"
|
||||
text: str = Field(min_length=20, max_length=120000)
|
||||
title: str | None = Field(default=None, max_length=160)
|
||||
source_note: str = Field(default="", max_length=800)
|
||||
|
||||
|
||||
class PersonaSourceDocumentResponse(BaseModel):
|
||||
source_id: str
|
||||
doc_id: int | None
|
||||
doc_uri: str
|
||||
title: str
|
||||
source_kind: PersonaSourceKind
|
||||
kb_kind: str
|
||||
license_class: Literal["A", "B", "C", "D"] = "B"
|
||||
external_llm_ok: bool = True
|
||||
content_hash: str
|
||||
chunk_count: int
|
||||
chunks_indexed: int
|
||||
embedded: bool
|
||||
degraded: bool = False
|
||||
pii_entities_masked: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PersonaGenerationEvidence(BaseModel):
|
||||
chunk_id: int
|
||||
source_id: str
|
||||
score: float
|
||||
kb_kind: str
|
||||
heading_path: str | None = None
|
||||
excerpt: str
|
||||
|
||||
|
||||
class PersonaDraftGenerateRequest(BaseModel):
|
||||
source_text: str | None = Field(default=None, min_length=20, max_length=30000)
|
||||
source_ids: list[str] = Field(default_factory=list, max_length=12)
|
||||
source_kind: PersonaSourceKind = "mixed_notes"
|
||||
code_hint: str | None = Field(default=None, max_length=24)
|
||||
display_name_hint: str | None = Field(default=None, max_length=80)
|
||||
difficulty: Literal["easy", "moderate", "hard"] = "moderate"
|
||||
theory_target: list[str] = Field(default_factory=lambda: ["humanistic"])
|
||||
generation_goal: str = Field(default="", max_length=800)
|
||||
|
||||
|
||||
class PersonaDraftGenerateResponse(BaseModel):
|
||||
draft: PersonaDraftPayload
|
||||
source_summary: str = ""
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
pii_entities_masked: list[str] = Field(default_factory=list)
|
||||
source_references: list[PersonaSourceDocumentResponse] = Field(default_factory=list)
|
||||
evidence_chunks: list[PersonaGenerationEvidence] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PersonaDraftDetail(PersonaReviewSummary):
|
||||
demographics: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
|
||||
presenting: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
|
||||
history: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
|
||||
big5: dict[str, float]
|
||||
resistance: dict[str, float]
|
||||
speech_style: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
|
||||
affect_baseline: dict[str, float]
|
||||
ccd: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
|
||||
dsm5_dimensional: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
|
||||
triggers: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
|
||||
|
||||
|
||||
def _first_text_value(data: dict[str, Any]) -> str:
|
||||
for value in data.values():
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def persona_summary(entry: CatalogPersona) -> PersonaSummary:
|
||||
card = entry.card
|
||||
return PersonaSummary(
|
||||
persona_id=entry.persona_id,
|
||||
code=card.code,
|
||||
version=entry.version,
|
||||
status="approved",
|
||||
display_name=card.display_name,
|
||||
difficulty=card.difficulty,
|
||||
theory_target=card.theory_target,
|
||||
demographics=card.demographics,
|
||||
presenting_summary=_first_text_value(card.presenting),
|
||||
source=entry.source,
|
||||
degraded=entry.degraded,
|
||||
)
|
||||
|
||||
|
||||
def persona_review_summary(entry: PersonaReviewItem) -> PersonaReviewSummary:
|
||||
return PersonaReviewSummary(
|
||||
persona_id=entry.persona_id,
|
||||
code=entry.code,
|
||||
version=entry.version,
|
||||
status=entry.status,
|
||||
display_name=entry.display_name,
|
||||
difficulty=entry.difficulty,
|
||||
theory_target=entry.theory_target,
|
||||
source_provenance=entry.source_provenance,
|
||||
is_synthetic=entry.is_synthetic,
|
||||
created_at=entry.created_at,
|
||||
approved_at=entry.approved_at,
|
||||
)
|
||||
|
||||
|
||||
def persona_draft_detail(entry: PersonaDraftRecord) -> PersonaDraftDetail:
|
||||
card = entry.card
|
||||
return PersonaDraftDetail(
|
||||
**persona_review_summary(entry.review).model_dump(),
|
||||
demographics=card.demographics,
|
||||
presenting=card.presenting,
|
||||
history=card.history,
|
||||
big5=card.big5,
|
||||
resistance=card.resistance,
|
||||
speech_style=card.speech_style,
|
||||
affect_baseline=card.affect_baseline,
|
||||
ccd=card.ccd,
|
||||
dsm5_dimensional=card.dsm5_dimensional,
|
||||
triggers=card.triggers,
|
||||
)
|
||||
|
||||
|
||||
def persona_card_from_draft_payload(request: PersonaDraftPayload) -> PersonaCard:
|
||||
code = request.code.strip().upper()
|
||||
display_name = request.display_name.strip()
|
||||
if not code:
|
||||
raise ValueError("persona code is required")
|
||||
if not display_name:
|
||||
raise ValueError("display_name is required")
|
||||
theory_target = [value.strip().lower() for value in request.theory_target if value.strip()]
|
||||
return PersonaCard(
|
||||
code=code,
|
||||
display_name=display_name,
|
||||
difficulty=request.difficulty,
|
||||
theory_target=theory_target,
|
||||
demographics=request.demographics,
|
||||
presenting=request.presenting,
|
||||
history=request.history,
|
||||
big5=request.big5,
|
||||
resistance=request.resistance,
|
||||
speech_style=request.speech_style,
|
||||
affect_baseline=request.affect_baseline,
|
||||
ccd=request.ccd,
|
||||
dsm5_dimensional=request.dsm5_dimensional,
|
||||
triggers=request.triggers,
|
||||
source_provenance=request.source_provenance.strip(),
|
||||
is_synthetic=request.is_synthetic,
|
||||
)
|
||||
|
|
@ -22,7 +22,7 @@ from pydantic import BaseModel, Field
|
|||
|
||||
from ..db import acquire, get_pool
|
||||
from ..deps import AIView, Principal, Role, require_role
|
||||
from ..services import live_coach, rag
|
||||
from ..services import rag, source_pack_sync
|
||||
|
||||
router = APIRouter(prefix="/kb", tags=["kb"])
|
||||
|
||||
|
|
@ -326,6 +326,8 @@ async def index_document(
|
|||
# 관리자 인덱싱은 RLS 미적용(쓰기 — kb 스키마 직접). role 주입 없이 acquire.
|
||||
async with acquire() as conn:
|
||||
result = await rag.index_document(conn, req)
|
||||
except rag.IndexPolicyViolation as e:
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) from e
|
||||
except rag.NotConfigured as e:
|
||||
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"RAG not configured: {e}")
|
||||
except RuntimeError as e:
|
||||
|
|
@ -354,64 +356,36 @@ async def sync_live_coach_source_packs(
|
|||
evaluator RAG 검색에도 올린다. source row를 먼저 upsert한 뒤 content_hash 기반 증분 색인을
|
||||
수행한다. 임베딩 모델 미가용 시 BM25-only degraded 색인으로 이어진다.
|
||||
"""
|
||||
source_rows = live_coach.build_rag_source_rows()
|
||||
index_payloads = live_coach.build_rag_index_payloads()
|
||||
source_rows, index_payloads = source_pack_sync.build_repo_source_pack_manifest()
|
||||
if not source_rows or not index_payloads:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="live coach source packs are empty",
|
||||
)
|
||||
|
||||
items: list[LiveCoachSourcePackSyncItem] = []
|
||||
source_row_by_id = {row["source_id"]: row for row in source_rows}
|
||||
try:
|
||||
async with acquire() as conn:
|
||||
for row in source_rows:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO kb.source
|
||||
(source_id, title, kb_kind, license_class, origin_path, citation, external_llm_ok)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (source_id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
kb_kind = EXCLUDED.kb_kind,
|
||||
license_class = EXCLUDED.license_class,
|
||||
origin_path = EXCLUDED.origin_path,
|
||||
citation = EXCLUDED.citation,
|
||||
external_llm_ok = EXCLUDED.external_llm_ok
|
||||
""",
|
||||
row["source_id"],
|
||||
row["title"],
|
||||
row["kb_kind"],
|
||||
row["license_class"],
|
||||
row["origin_path"],
|
||||
row["citation"],
|
||||
row["external_llm_ok"],
|
||||
)
|
||||
for payload in index_payloads:
|
||||
if payload["source_id"] not in source_row_by_id:
|
||||
continue
|
||||
result = await rag.index_document(conn, rag.IndexRequest(**payload))
|
||||
items.append(
|
||||
LiveCoachSourcePackSyncItem(
|
||||
source_id=payload["source_id"],
|
||||
doc_id=result.doc_id,
|
||||
chunks_indexed=result.chunks_indexed,
|
||||
skipped_unchanged=result.skipped_unchanged,
|
||||
embedded=result.embedded,
|
||||
degraded=result.degraded,
|
||||
)
|
||||
)
|
||||
result = await source_pack_sync.sync_repo_source_packs(conn, apply=True)
|
||||
except rag.NotConfigured as exc:
|
||||
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"RAG not configured: {exc}") from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {exc}") from exc
|
||||
|
||||
return LiveCoachSourcePackSyncResponse(
|
||||
sources_upserted=len(source_rows),
|
||||
chunks_indexed=sum(item.chunks_indexed for item in items),
|
||||
skipped_unchanged=sum(1 for item in items if item.skipped_unchanged),
|
||||
embedded=all(item.embedded for item in items) if items else False,
|
||||
degraded=any(item.degraded for item in items),
|
||||
items=items,
|
||||
sources_upserted=result.sources_upserted,
|
||||
chunks_indexed=result.chunks_indexed,
|
||||
skipped_unchanged=result.skipped_unchanged,
|
||||
embedded=result.embedded,
|
||||
degraded=result.degraded,
|
||||
items=[
|
||||
LiveCoachSourcePackSyncItem(
|
||||
source_id=item.source_id,
|
||||
doc_id=item.doc_id,
|
||||
chunks_indexed=item.chunks_indexed,
|
||||
skipped_unchanged=item.skipped_unchanged,
|
||||
embedded=item.embedded,
|
||||
degraded=item.degraded,
|
||||
)
|
||||
for item in result.items
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,16 +9,11 @@ import uuid
|
|||
from typing import Annotated, Any, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..db import acquire
|
||||
from ..deps import CurrentPrincipal, Principal, Role, require_role
|
||||
from ..deps import AIView
|
||||
from ..persona_repository import (
|
||||
CatalogPersona,
|
||||
PersonaDraftRecord,
|
||||
PersonaReviewAction,
|
||||
PersonaReviewItem,
|
||||
archive_persona_family,
|
||||
create_persona_draft,
|
||||
create_persona_revision_from_existing,
|
||||
|
|
@ -28,15 +23,30 @@ from ..persona_repository import (
|
|||
update_persona_draft,
|
||||
update_persona_review_status,
|
||||
)
|
||||
from ..persona_read_model import (
|
||||
PersonaDraftDetail,
|
||||
PersonaDraftGenerateRequest,
|
||||
PersonaDraftGenerateResponse,
|
||||
PersonaDraftPayload,
|
||||
PersonaGenerationEvidence,
|
||||
PersonaReviewDecisionRequest,
|
||||
PersonaReviewSummary,
|
||||
PersonaRevisionRequest,
|
||||
PersonaSourceDocumentRequest,
|
||||
PersonaSourceDocumentResponse,
|
||||
PersonaSourceKind,
|
||||
PersonaSummary,
|
||||
persona_card_from_draft_payload,
|
||||
persona_draft_detail,
|
||||
persona_review_summary,
|
||||
persona_summary,
|
||||
)
|
||||
from ..engine_client import EngineError, EngineMessage, GenerateRequest, engine_client
|
||||
from ..services import rag
|
||||
from ..services.guardrail import mask_pii
|
||||
from ..services.persona import PersonaCard
|
||||
|
||||
router = APIRouter(prefix="/personas", tags=["personas"])
|
||||
TeacherOrAdmin = Annotated[Principal, Depends(require_role(Role.TEACHER, Role.ADMIN))]
|
||||
JSON_OBJECT_FIELD = {"additionalProperties": True}
|
||||
PersonaSourceKind = Literal["client_record", "textbook_guide", "mixed_notes"]
|
||||
PERSONA_SOURCE_KB_KIND: dict[str, str] = {
|
||||
"client_record": "diagnostic",
|
||||
"textbook_guide": "theory",
|
||||
|
|
@ -47,215 +57,45 @@ PERSONA_SOURCE_CITATION: dict[str, str] = {
|
|||
"textbook_guide": "교수자 첨부 교재/가이드 환언·발췌 근거 — 저작권 검수 필요",
|
||||
"mixed_notes": "교수자 첨부 혼합 메모 PII 마스킹 파생본",
|
||||
}
|
||||
PERSONA_DRAFT_PROMPT_BUNDLE_ID = "persona-draft-rag"
|
||||
PERSONA_DRAFT_PROMPT_BUNDLE_VERSION = "2026-06-28.1"
|
||||
PERSONA_DRAFT_SYSTEM_PROMPT = (
|
||||
"출력은 반드시 structured_schema를 따른다. code는 P숫자 형식을 선호하되 "
|
||||
"힌트가 없으면 빈 문자열 대신 임시값 P로 둔다. source_provenance에는 "
|
||||
"RAG source_id와 첨부 근거 기반 초안임을 남긴다. evidence chunk id를 "
|
||||
"임상 필드 본문에 그대로 노출하지 않는다."
|
||||
)
|
||||
PERSONA_DRAFT_USER_PROMPT_PREAMBLE = (
|
||||
"너는 Vignette 임상 콘텐츠 저작 보조자다. 아래 RAG 근거 청크만 바탕으로 교육용 "
|
||||
"가상내담자 페르소나 초안을 만든다. 첨부 원문은 KB 문서가 SSOT이며, 근거 밖 내용을 "
|
||||
"임의로 꾸며 핵심 임상 정보처럼 쓰지 않는다. 실제 개인정보는 이미 마스킹됐으며, "
|
||||
"원문 표현을 복사하지 말고 "
|
||||
"범주화·합성화된 임상 훈련용 설정으로 변환한다. CCD/DSM/역린은 런타임 내부 설정이므로 "
|
||||
"내담자 발화에 직접 노출되지 않는 형태로 작성한다."
|
||||
)
|
||||
|
||||
|
||||
class PersonaSummary(BaseModel):
|
||||
persona_id: str | None = None
|
||||
code: str
|
||||
version: int | None = None
|
||||
status: Literal["approved"] = "approved"
|
||||
display_name: str
|
||||
difficulty: str
|
||||
theory_target: list[str]
|
||||
demographics: dict[str, Any]
|
||||
presenting_summary: str
|
||||
voice_preset: str | None = None
|
||||
source: str = "database"
|
||||
degraded: bool = False
|
||||
|
||||
|
||||
class PersonaReviewSummary(BaseModel):
|
||||
persona_id: str
|
||||
code: str
|
||||
version: int
|
||||
status: Literal["draft", "review", "approved", "archived"]
|
||||
display_name: str
|
||||
difficulty: str
|
||||
theory_target: list[str]
|
||||
source_provenance: str
|
||||
is_synthetic: bool
|
||||
created_at: str | None = None
|
||||
approved_at: str | None = None
|
||||
|
||||
|
||||
class PersonaReviewDecisionRequest(BaseModel):
|
||||
action: PersonaReviewAction
|
||||
|
||||
|
||||
class PersonaRevisionRequest(BaseModel):
|
||||
submit_for_review: bool = False
|
||||
|
||||
|
||||
class PersonaDraftPayload(BaseModel):
|
||||
code: str = Field(min_length=1, max_length=24)
|
||||
display_name: str = Field(min_length=1, max_length=80)
|
||||
difficulty: Literal["easy", "moderate", "hard"]
|
||||
theory_target: list[str] = Field(default_factory=list)
|
||||
demographics: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD)
|
||||
presenting: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD)
|
||||
history: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD)
|
||||
big5: dict[str, float] = Field(default_factory=dict)
|
||||
resistance: dict[str, float] = Field(default_factory=dict)
|
||||
speech_style: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD)
|
||||
affect_baseline: dict[str, float] = Field(default_factory=dict)
|
||||
ccd: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD)
|
||||
dsm5_dimensional: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD)
|
||||
triggers: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD)
|
||||
source_provenance: str = Field(default="", max_length=240)
|
||||
is_synthetic: bool = True
|
||||
submit_for_review: bool = False
|
||||
|
||||
|
||||
class PersonaSourceDocumentRequest(BaseModel):
|
||||
filename: str = Field(min_length=1, max_length=240)
|
||||
source_kind: PersonaSourceKind = "mixed_notes"
|
||||
text: str = Field(min_length=20, max_length=120000)
|
||||
title: str | None = Field(default=None, max_length=160)
|
||||
source_note: str = Field(default="", max_length=800)
|
||||
|
||||
|
||||
class PersonaSourceDocumentResponse(BaseModel):
|
||||
source_id: str
|
||||
doc_id: int | None
|
||||
doc_uri: str
|
||||
title: str
|
||||
source_kind: PersonaSourceKind
|
||||
kb_kind: str
|
||||
license_class: Literal["A", "B", "C", "D"] = "B"
|
||||
external_llm_ok: bool = True
|
||||
content_hash: str
|
||||
chunk_count: int
|
||||
chunks_indexed: int
|
||||
embedded: bool
|
||||
degraded: bool = False
|
||||
pii_entities_masked: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PersonaGenerationEvidence(BaseModel):
|
||||
chunk_id: int
|
||||
source_id: str
|
||||
score: float
|
||||
kb_kind: str
|
||||
heading_path: str | None = None
|
||||
excerpt: str
|
||||
|
||||
|
||||
class PersonaDraftGenerateRequest(BaseModel):
|
||||
source_text: str | None = Field(default=None, min_length=20, max_length=30000)
|
||||
source_ids: list[str] = Field(default_factory=list, max_length=12)
|
||||
source_kind: PersonaSourceKind = "mixed_notes"
|
||||
code_hint: str | None = Field(default=None, max_length=24)
|
||||
display_name_hint: str | None = Field(default=None, max_length=80)
|
||||
difficulty: Literal["easy", "moderate", "hard"] = "moderate"
|
||||
theory_target: list[str] = Field(default_factory=lambda: ["humanistic"])
|
||||
generation_goal: str = Field(default="", max_length=800)
|
||||
|
||||
|
||||
class PersonaDraftGenerateResponse(BaseModel):
|
||||
draft: PersonaDraftPayload
|
||||
source_summary: str = ""
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
pii_entities_masked: list[str] = Field(default_factory=list)
|
||||
source_references: list[PersonaSourceDocumentResponse] = Field(default_factory=list)
|
||||
evidence_chunks: list[PersonaGenerationEvidence] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PersonaDraftDetail(PersonaReviewSummary):
|
||||
demographics: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
|
||||
presenting: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
|
||||
history: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
|
||||
big5: dict[str, float]
|
||||
resistance: dict[str, float]
|
||||
speech_style: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
|
||||
affect_baseline: dict[str, float]
|
||||
ccd: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
|
||||
dsm5_dimensional: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
|
||||
triggers: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
|
||||
|
||||
|
||||
def _first_text_value(data: dict[str, Any]) -> str:
|
||||
for value in data.values():
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _summary(entry: CatalogPersona) -> PersonaSummary:
|
||||
card = entry.card
|
||||
return PersonaSummary(
|
||||
persona_id=entry.persona_id,
|
||||
code=card.code,
|
||||
version=entry.version,
|
||||
status="approved",
|
||||
display_name=card.display_name,
|
||||
difficulty=card.difficulty,
|
||||
theory_target=card.theory_target,
|
||||
demographics=card.demographics,
|
||||
presenting_summary=_first_text_value(card.presenting),
|
||||
source=entry.source,
|
||||
degraded=entry.degraded,
|
||||
def _persona_draft_prompt_bundle() -> dict[str, str]:
|
||||
payload = "\n".join(
|
||||
[
|
||||
PERSONA_DRAFT_PROMPT_BUNDLE_ID,
|
||||
PERSONA_DRAFT_PROMPT_BUNDLE_VERSION,
|
||||
PERSONA_DRAFT_SYSTEM_PROMPT,
|
||||
PERSONA_DRAFT_USER_PROMPT_PREAMBLE,
|
||||
]
|
||||
)
|
||||
return {
|
||||
"id": PERSONA_DRAFT_PROMPT_BUNDLE_ID,
|
||||
"version": PERSONA_DRAFT_PROMPT_BUNDLE_VERSION,
|
||||
"hash": hashlib.sha256(payload.encode("utf-8")).hexdigest()[:12],
|
||||
}
|
||||
|
||||
|
||||
def _review_summary(entry: PersonaReviewItem) -> PersonaReviewSummary:
|
||||
return PersonaReviewSummary(
|
||||
persona_id=entry.persona_id,
|
||||
code=entry.code,
|
||||
version=entry.version,
|
||||
status=entry.status,
|
||||
display_name=entry.display_name,
|
||||
difficulty=entry.difficulty,
|
||||
theory_target=entry.theory_target,
|
||||
source_provenance=entry.source_provenance,
|
||||
is_synthetic=entry.is_synthetic,
|
||||
created_at=entry.created_at,
|
||||
approved_at=entry.approved_at,
|
||||
)
|
||||
|
||||
|
||||
def _draft_detail(entry: PersonaDraftRecord) -> PersonaDraftDetail:
|
||||
card = entry.card
|
||||
return PersonaDraftDetail(
|
||||
**_review_summary(entry.review).model_dump(),
|
||||
demographics=card.demographics,
|
||||
presenting=card.presenting,
|
||||
history=card.history,
|
||||
big5=card.big5,
|
||||
resistance=card.resistance,
|
||||
speech_style=card.speech_style,
|
||||
affect_baseline=card.affect_baseline,
|
||||
ccd=card.ccd,
|
||||
dsm5_dimensional=card.dsm5_dimensional,
|
||||
triggers=card.triggers,
|
||||
)
|
||||
|
||||
|
||||
def _card_from_draft_payload(request: PersonaDraftPayload) -> PersonaCard:
|
||||
code = request.code.strip().upper()
|
||||
display_name = request.display_name.strip()
|
||||
if not code:
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="persona code is required")
|
||||
if not display_name:
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="display_name is required")
|
||||
theory_target = [value.strip().lower() for value in request.theory_target if value.strip()]
|
||||
return PersonaCard(
|
||||
code=code,
|
||||
display_name=display_name,
|
||||
difficulty=request.difficulty,
|
||||
theory_target=theory_target,
|
||||
demographics=request.demographics,
|
||||
presenting=request.presenting,
|
||||
history=request.history,
|
||||
big5=request.big5,
|
||||
resistance=request.resistance,
|
||||
speech_style=request.speech_style,
|
||||
affect_baseline=request.affect_baseline,
|
||||
ccd=request.ccd,
|
||||
dsm5_dimensional=request.dsm5_dimensional,
|
||||
triggers=request.triggers,
|
||||
source_provenance=request.source_provenance.strip(),
|
||||
is_synthetic=request.is_synthetic,
|
||||
)
|
||||
def _card_from_draft_payload(request: PersonaDraftPayload):
|
||||
try:
|
||||
return persona_card_from_draft_payload(request)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def _safe_doc_segment(value: str) -> str:
|
||||
|
|
@ -299,6 +139,63 @@ def _source_content_hash(chunks: list[dict[str, Any]]) -> str:
|
|||
return h.hexdigest()
|
||||
|
||||
|
||||
def _raw_source_content_hash(request: PersonaSourceDocumentRequest) -> str:
|
||||
h = hashlib.sha256()
|
||||
h.update(request.text.encode("utf-8"))
|
||||
h.update(request.filename.encode("utf-8"))
|
||||
h.update(request.source_kind.encode("utf-8"))
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _raw_source_artifact_id(source_id: str) -> str:
|
||||
return f"{source_id}_raw"
|
||||
|
||||
|
||||
async def _record_persona_raw_source_artifact(
|
||||
conn: Any,
|
||||
*,
|
||||
request: PersonaSourceDocumentRequest,
|
||||
principal: Principal,
|
||||
source_id: str,
|
||||
doc_uri: str,
|
||||
content_hash: str,
|
||||
masked_entities: list[str],
|
||||
license_class: str,
|
||||
) -> None:
|
||||
summary = {
|
||||
"source_kind": request.source_kind,
|
||||
"filename": request.filename,
|
||||
"title": _source_title(request),
|
||||
"pii_entities_masked": masked_entities,
|
||||
"derived_source_id": source_id,
|
||||
"raw_text_not_indexed": True,
|
||||
"storage": "hash_only",
|
||||
}
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO kb.raw_source_artifact
|
||||
(raw_source_id, derived_source_id, owner_user_id, doc_uri, content_hash,
|
||||
storage_uri, sealed_blob, license_class, redaction_summary, external_llm_ok)
|
||||
VALUES ($1, $2, $3::uuid, $4, $5, NULL, NULL, $6, $7::jsonb, FALSE)
|
||||
ON CONFLICT (raw_source_id) DO UPDATE SET
|
||||
derived_source_id = EXCLUDED.derived_source_id,
|
||||
owner_user_id = EXCLUDED.owner_user_id,
|
||||
doc_uri = EXCLUDED.doc_uri,
|
||||
content_hash = EXCLUDED.content_hash,
|
||||
license_class = EXCLUDED.license_class,
|
||||
redaction_summary = EXCLUDED.redaction_summary,
|
||||
external_llm_ok = FALSE
|
||||
""",
|
||||
_raw_source_artifact_id(source_id),
|
||||
source_id,
|
||||
principal.user_id,
|
||||
f"{doc_uri}.raw",
|
||||
content_hash,
|
||||
license_class,
|
||||
json.dumps(summary, ensure_ascii=False),
|
||||
)
|
||||
|
||||
|
||||
def _source_title(request: PersonaSourceDocumentRequest) -> str:
|
||||
return (request.title or request.filename).strip()
|
||||
|
||||
|
|
@ -315,6 +212,8 @@ async def _register_persona_source_document(
|
|||
f"persona-authoring/{principal.user_id}/"
|
||||
f"{source_id}/{_safe_doc_segment(request.filename)}"
|
||||
)
|
||||
raw_source_id = _raw_source_artifact_id(source_id)
|
||||
raw_content_hash = _raw_source_content_hash(request)
|
||||
kb_kind = PERSONA_SOURCE_KB_KIND.get(request.source_kind, "ko_context")
|
||||
license_class: Literal["A", "B", "C", "D"] = "B"
|
||||
external_llm_ok = True
|
||||
|
|
@ -340,7 +239,10 @@ async def _register_persona_source_document(
|
|||
"pii_entities_masked": masked.entities,
|
||||
"license_class": license_class,
|
||||
"external_llm_ok": external_llm_ok,
|
||||
"raw_source_not_stored": True,
|
||||
"raw_source_id": raw_source_id,
|
||||
"raw_source_content_hash": raw_content_hash,
|
||||
"raw_source_not_indexed": True,
|
||||
"raw_source_storage": "hash_only",
|
||||
},
|
||||
"token_count": max(1, len(chunk) // 4),
|
||||
}
|
||||
|
|
@ -374,7 +276,19 @@ async def _register_persona_source_document(
|
|||
request.filename,
|
||||
PERSONA_SOURCE_CITATION.get(request.source_kind, "교수자 첨부 PII 마스킹 파생본"),
|
||||
)
|
||||
await _record_persona_raw_source_artifact(
|
||||
conn,
|
||||
request=request,
|
||||
principal=principal,
|
||||
source_id=source_id,
|
||||
doc_uri=doc_uri,
|
||||
content_hash=raw_content_hash,
|
||||
masked_entities=masked.entities,
|
||||
license_class=license_class,
|
||||
)
|
||||
result = await rag.index_document(conn, index_req)
|
||||
except rag.IndexPolicyViolation as exc:
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
except rag.NotConfigured as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
|
|
@ -698,7 +612,7 @@ async def list_personas(response: Response, _principal: CurrentPrincipal) -> lis
|
|||
else:
|
||||
response.headers["X-Vignette-Catalog-Source"] = "database"
|
||||
|
||||
return [_summary(entry) for entry in personas]
|
||||
return [persona_summary(entry) for entry in personas]
|
||||
|
||||
|
||||
@router.get("/review", response_model=list[PersonaReviewSummary])
|
||||
|
|
@ -712,7 +626,7 @@ async def list_persona_reviews(principal: TeacherOrAdmin) -> list[PersonaReviewS
|
|||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="persona review queue database unavailable",
|
||||
) from exc
|
||||
return [_review_summary(entry) for entry in queue]
|
||||
return [persona_review_summary(entry) for entry in queue]
|
||||
|
||||
|
||||
@router.post("/drafts", response_model=PersonaReviewSummary, status_code=status.HTTP_201_CREATED)
|
||||
|
|
@ -736,7 +650,7 @@ async def create_persona_draft_route(
|
|||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="persona draft database unavailable",
|
||||
) from exc
|
||||
return _review_summary(created)
|
||||
return persona_review_summary(created)
|
||||
|
||||
|
||||
@router.post("/{persona_id}/revisions", response_model=PersonaDraftDetail, status_code=status.HTTP_201_CREATED)
|
||||
|
|
@ -763,7 +677,7 @@ async def create_persona_revision_route(
|
|||
) from exc
|
||||
if record is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="approved persona not found")
|
||||
return _draft_detail(record)
|
||||
return persona_draft_detail(record)
|
||||
|
||||
|
||||
@router.delete("/{persona_id}", response_model=PersonaReviewSummary)
|
||||
|
|
@ -788,7 +702,7 @@ async def archive_persona_route(
|
|||
) from exc
|
||||
if archived is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="persona not found")
|
||||
return _review_summary(archived)
|
||||
return persona_review_summary(archived)
|
||||
|
||||
|
||||
@router.post("/sources", response_model=PersonaSourceDocumentResponse, status_code=status.HTTP_201_CREATED)
|
||||
|
|
@ -861,13 +775,9 @@ async def generate_persona_draft_route(
|
|||
query=evidence_query or "페르소나 저작 근거",
|
||||
)
|
||||
evidence_text = _format_generation_evidence(evidence)
|
||||
prompt_bundle = _persona_draft_prompt_bundle()
|
||||
prompt = (
|
||||
"너는 Vignette 임상 콘텐츠 저작 보조자다. 아래 RAG 근거 청크만 바탕으로 교육용 "
|
||||
"가상내담자 페르소나 초안을 만든다. 첨부 원문은 KB 문서가 SSOT이며, 근거 밖 내용을 "
|
||||
"임의로 꾸며 핵심 임상 정보처럼 쓰지 않는다. 실제 개인정보는 이미 마스킹됐으며, "
|
||||
"원문 표현을 복사하지 말고 "
|
||||
"범주화·합성화된 임상 훈련용 설정으로 변환한다. CCD/DSM/역린은 런타임 내부 설정이므로 "
|
||||
"내담자 발화에 직접 노출되지 않는 형태로 작성한다.\n\n"
|
||||
f"{PERSONA_DRAFT_USER_PROMPT_PREAMBLE}\n\n"
|
||||
f"자료 종류: {request.source_kind}\n"
|
||||
f"RAG source_ids: {source_ids}\n"
|
||||
f"코드 힌트: {code_hint or '미정'}\n"
|
||||
|
|
@ -883,12 +793,7 @@ async def generate_persona_draft_route(
|
|||
messages=[
|
||||
EngineMessage(
|
||||
role="system",
|
||||
content=(
|
||||
"출력은 반드시 structured_schema를 따른다. code는 P숫자 형식을 선호하되 "
|
||||
"힌트가 없으면 빈 문자열 대신 임시값 P로 둔다. source_provenance에는 "
|
||||
"RAG source_id와 첨부 근거 기반 초안임을 남긴다. evidence chunk id를 "
|
||||
"임상 필드 본문에 그대로 노출하지 않는다."
|
||||
),
|
||||
content=PERSONA_DRAFT_SYSTEM_PROMPT,
|
||||
),
|
||||
EngineMessage(role="user", content=prompt),
|
||||
],
|
||||
|
|
@ -897,6 +802,7 @@ async def generate_persona_draft_route(
|
|||
structured_schema=_persona_generation_schema(),
|
||||
metadata={
|
||||
"feature": "persona_draft_generation",
|
||||
"prompt_bundle": prompt_bundle,
|
||||
"source_kind": request.source_kind,
|
||||
"source_ids": source_ids,
|
||||
},
|
||||
|
|
@ -911,6 +817,7 @@ async def generate_persona_draft_route(
|
|||
payload = response.structured or _json_payload_from_generation(response.text)
|
||||
draft = _coerce_generated_draft(payload, request)
|
||||
provenance = (
|
||||
f"prompt={prompt_bundle['id']}@{prompt_bundle['version']}#{prompt_bundle['hash']}; "
|
||||
f"RAG sources={','.join(source_ids)}; "
|
||||
f"chunks={','.join(str(item.chunk_id) for item in evidence)}"
|
||||
)
|
||||
|
|
@ -947,7 +854,7 @@ async def get_persona_draft_route(
|
|||
) from exc
|
||||
if record is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="persona draft not found")
|
||||
return _draft_detail(record)
|
||||
return persona_draft_detail(record)
|
||||
|
||||
|
||||
@router.put("/drafts/{persona_id}", response_model=PersonaReviewSummary)
|
||||
|
|
@ -975,7 +882,7 @@ async def update_persona_draft_route(
|
|||
) from exc
|
||||
if updated is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="persona draft not found or locked")
|
||||
return _review_summary(updated)
|
||||
return persona_review_summary(updated)
|
||||
|
||||
|
||||
@router.post("/review/{persona_id}", response_model=PersonaReviewSummary)
|
||||
|
|
@ -1005,4 +912,4 @@ async def decide_persona_review(
|
|||
status.HTTP_404_NOT_FOUND,
|
||||
detail="persona review item not found or not pending review",
|
||||
)
|
||||
return _review_summary(updated)
|
||||
return persona_review_summary(updated)
|
||||
|
|
|
|||
|
|
@ -43,6 +43,10 @@ class NotConfigured(RuntimeError):
|
|||
"""
|
||||
|
||||
|
||||
class IndexPolicyViolation(ValueError):
|
||||
"""Index request violates RAG source isolation policy."""
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 1. 정책 4-튜플 (설계서 §4.3) — 사전필터 + 회수가중치 + 리랭킹목표 + 주입방식
|
||||
# 3-AI 가 *같은 물리 테이블 kb.chunk* 를 다른 정책으로 검색한다.
|
||||
|
|
@ -149,6 +153,34 @@ class RetrievalResult:
|
|||
degraded: bool = False # reranker/embed fallback 여부(투명성)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class EpisodicTurnInput:
|
||||
"""app.turn_embedding writer input.
|
||||
|
||||
Only masked, client-visible client utterances should be passed here. Raw text,
|
||||
evaluator payloads, counselor turns, and CCD/answer-key material are not part of
|
||||
this contract.
|
||||
"""
|
||||
|
||||
turn_id: str
|
||||
session_id: str
|
||||
case_id: str
|
||||
seq: int
|
||||
text_masked: str
|
||||
speaker: str = "client"
|
||||
visible_to: Sequence[str] = ("client", "counselor", "evaluator")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class EpisodicEmbeddingWriteResult:
|
||||
"""Best-effort turn_embedding writer result."""
|
||||
|
||||
candidates: int
|
||||
inserted: int
|
||||
skipped: int
|
||||
degraded: bool = False
|
||||
|
||||
|
||||
# CRAG 게이트 임계값(설계서 §3.7 top1_score). 미달이면 호출부가 "관찰 프레이밍"으로 다운그레이드.
|
||||
# 주: 임의 가정값 — Phase 3 파일럿에서 분포 측정 후 확정(M14, "검증됨" 금지).
|
||||
CRAG_TOP1_THRESHOLD = 0.35
|
||||
|
|
@ -572,6 +604,91 @@ ORDER BY d.s_dense DESC
|
|||
LIMIT $4
|
||||
"""
|
||||
|
||||
_TURN_EMBEDDING_INSERT_SQL = """
|
||||
INSERT INTO app.turn_embedding
|
||||
(turn_id, case_id, session_id, seq, dense, sparse, context_prefix)
|
||||
VALUES ($1::uuid, $2::uuid, $3::uuid, $4, $5::vector, $6::jsonb, $7)
|
||||
ON CONFLICT (turn_id) DO NOTHING
|
||||
"""
|
||||
|
||||
|
||||
def episodic_turn_inputs_from_records(
|
||||
*,
|
||||
session_id: str,
|
||||
case_id: str,
|
||||
turns: Sequence[Any],
|
||||
) -> list[EpisodicTurnInput]:
|
||||
"""Build safe episodic embedding inputs from TurnRecord-like objects.
|
||||
|
||||
The writer is intentionally narrow: only client speaker turns that are visible
|
||||
to the client, have a DB turn_id, and have non-empty masked text are eligible.
|
||||
Raw `text`, evaluations, counselor messages, and evaluator-only turns are
|
||||
ignored by construction.
|
||||
"""
|
||||
inputs: list[EpisodicTurnInput] = []
|
||||
for turn in turns:
|
||||
turn_id = getattr(turn, "turn_id", None)
|
||||
text_masked = str(getattr(turn, "text_masked", "") or "").strip()
|
||||
visible_to = tuple(getattr(turn, "visible_to", ()) or ())
|
||||
if not turn_id or not text_masked:
|
||||
continue
|
||||
if getattr(turn, "speaker", None) != "client":
|
||||
continue
|
||||
if "client" not in visible_to:
|
||||
continue
|
||||
inputs.append(
|
||||
EpisodicTurnInput(
|
||||
turn_id=str(turn_id),
|
||||
session_id=session_id,
|
||||
case_id=case_id,
|
||||
seq=int(getattr(turn, "turn_seq", 0) or 0),
|
||||
text_masked=text_masked,
|
||||
speaker="client",
|
||||
visible_to=visible_to,
|
||||
)
|
||||
)
|
||||
return inputs
|
||||
|
||||
|
||||
async def write_persona_turn_embeddings(
|
||||
conn: "asyncpg.Connection",
|
||||
*,
|
||||
turns: Sequence[EpisodicTurnInput],
|
||||
) -> EpisodicEmbeddingWriteResult:
|
||||
"""Index masked client-visible client utterances into app.turn_embedding.
|
||||
|
||||
This is a technical writer only. It does not infer relationship or clinical
|
||||
facts, and it does not store raw text. Missing BGE-M3/pgvector is reported as
|
||||
NotConfigured so callers can skip without breaking session persistence.
|
||||
"""
|
||||
candidates = len(turns)
|
||||
inserted = 0
|
||||
for turn in turns:
|
||||
if turn.speaker != "client" or "client" not in tuple(turn.visible_to or ()):
|
||||
continue
|
||||
text = turn.text_masked.strip()
|
||||
if not text:
|
||||
continue
|
||||
eq = await asyncio.to_thread(embed_query, text)
|
||||
result = await conn.execute(
|
||||
_TURN_EMBEDDING_INSERT_SQL,
|
||||
turn.turn_id,
|
||||
turn.case_id,
|
||||
turn.session_id,
|
||||
int(turn.seq),
|
||||
_vector_literal(eq.dense),
|
||||
json.dumps(eq.sparse),
|
||||
None,
|
||||
)
|
||||
if isinstance(result, str) and result.endswith(" 1"):
|
||||
inserted += 1
|
||||
return EpisodicEmbeddingWriteResult(
|
||||
candidates=candidates,
|
||||
inserted=inserted,
|
||||
skipped=max(0, candidates - inserted),
|
||||
degraded=False,
|
||||
)
|
||||
|
||||
|
||||
async def retrieve_persona_memory(
|
||||
conn: "asyncpg.Connection",
|
||||
|
|
@ -694,6 +811,40 @@ def _content_hash(chunks: list[dict[str, Any]]) -> str:
|
|||
return h.hexdigest()
|
||||
|
||||
|
||||
def _truthy_meta_flag(meta: Any, *keys: str) -> bool:
|
||||
if not isinstance(meta, dict):
|
||||
return False
|
||||
for key in keys:
|
||||
if bool(meta.get(key)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _validate_index_chunks(req: IndexRequest) -> None:
|
||||
"""Fail closed before raw source artifacts can enter kb.chunk.
|
||||
|
||||
`sensitivity=3` means 원천격리. Those materials must stay outside the
|
||||
embedding/FTS index because kb.chunk is retrievable by design.
|
||||
"""
|
||||
for index, chunk in enumerate(req.chunks):
|
||||
meta = chunk.get("meta") or {}
|
||||
sensitivity = chunk.get("sensitivity")
|
||||
try:
|
||||
sensitivity_int = int(sensitivity) if sensitivity is not None else 0
|
||||
except (TypeError, ValueError):
|
||||
sensitivity_int = 0
|
||||
if sensitivity_int >= 3 or _truthy_meta_flag(
|
||||
meta,
|
||||
"raw_source",
|
||||
"raw_source_artifact",
|
||||
"raw_source_isolated",
|
||||
):
|
||||
raise IndexPolicyViolation(
|
||||
"raw source artifacts must not be indexed in kb.chunk "
|
||||
f"(source_id={req.source_id}, chunk_index={index})"
|
||||
)
|
||||
|
||||
|
||||
async def index_document(
|
||||
conn: "asyncpg.Connection",
|
||||
req: IndexRequest,
|
||||
|
|
@ -711,6 +862,7 @@ async def index_document(
|
|||
|
||||
Raises: NotConfigured — DB(kb 스키마/vector) 미가용.
|
||||
"""
|
||||
_validate_index_chunks(req)
|
||||
content_hash = req.content_hash or _content_hash(req.chunks)
|
||||
|
||||
# (1) 증분 — 동일 source/uri/version 활성본의 content_hash 비교
|
||||
|
|
@ -825,14 +977,19 @@ async def index_document(
|
|||
|
||||
__all__ = [
|
||||
"NotConfigured",
|
||||
"IndexPolicyViolation",
|
||||
"AIRole",
|
||||
"RetrievalPolicy",
|
||||
"POLICIES",
|
||||
"RetrievedChunk",
|
||||
"RetrievalResult",
|
||||
"EpisodicTurnInput",
|
||||
"EpisodicEmbeddingWriteResult",
|
||||
"CRAG_TOP1_THRESHOLD",
|
||||
"EmbeddedQuery",
|
||||
"embed_query",
|
||||
"episodic_turn_inputs_from_records",
|
||||
"write_persona_turn_embeddings",
|
||||
"apply_contextual_prefix",
|
||||
"search_kb",
|
||||
"retrieve_persona_memory",
|
||||
|
|
|
|||
194
apps/api/app/services/source_pack_sync.py
Normal file
194
apps/api/app/services/source_pack_sync.py
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
"""Repo-managed source pack sync helpers.
|
||||
|
||||
This keeps CLI and admin API sync behavior on the same content_hash/version
|
||||
path. The source pack content itself stays in data files owned outside this
|
||||
service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from . import live_coach, rag
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RepoSourcePackSyncItem:
|
||||
source_id: str
|
||||
doc_uri: str
|
||||
previous_version: int | None
|
||||
new_version: int
|
||||
content_hash: str
|
||||
doc_id: int | None
|
||||
chunks_indexed: int
|
||||
skipped_unchanged: bool
|
||||
embedded: bool
|
||||
degraded: bool = False
|
||||
applied: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RepoSourcePackSyncResult:
|
||||
sources_upserted: int
|
||||
manifest_count: int
|
||||
chunks_indexed: int
|
||||
skipped_unchanged: int
|
||||
embedded: bool
|
||||
degraded: bool
|
||||
applied: bool
|
||||
items: list[RepoSourcePackSyncItem]
|
||||
|
||||
|
||||
def build_repo_source_pack_manifest() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Return repo-managed source rows and RAG index payloads without DB access."""
|
||||
|
||||
return live_coach.build_rag_source_rows(), live_coach.build_rag_index_payloads()
|
||||
|
||||
|
||||
def _latest_version(row: Any | None) -> int | None:
|
||||
if not row:
|
||||
return None
|
||||
value = row["version"]
|
||||
return int(value) if value is not None else None
|
||||
|
||||
|
||||
async def _latest_active_document(conn: Any, *, source_id: str, doc_uri: str) -> Any | None:
|
||||
return await conn.fetchrow(
|
||||
"""
|
||||
SELECT doc_id, version, content_hash
|
||||
FROM kb.document
|
||||
WHERE source_id = $1 AND doc_uri = $2 AND is_active
|
||||
ORDER BY version DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
source_id,
|
||||
doc_uri,
|
||||
)
|
||||
|
||||
|
||||
async def _upsert_source_rows(conn: Any, source_rows: list[dict[str, Any]]) -> None:
|
||||
for row in source_rows:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO kb.source
|
||||
(source_id, title, kb_kind, license_class, origin_path, citation, external_llm_ok)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (source_id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
kb_kind = EXCLUDED.kb_kind,
|
||||
license_class = EXCLUDED.license_class,
|
||||
origin_path = EXCLUDED.origin_path,
|
||||
citation = EXCLUDED.citation,
|
||||
external_llm_ok = EXCLUDED.external_llm_ok
|
||||
""",
|
||||
row["source_id"],
|
||||
row["title"],
|
||||
row["kb_kind"],
|
||||
row["license_class"],
|
||||
row["origin_path"],
|
||||
row["citation"],
|
||||
row["external_llm_ok"],
|
||||
)
|
||||
|
||||
|
||||
async def sync_repo_source_packs(conn: Any, *, apply: bool = False) -> RepoSourcePackSyncResult:
|
||||
"""Compare or apply repo-managed source packs against kb.document.
|
||||
|
||||
Dry-run mode still reads the active DB document row so it can report whether
|
||||
the next apply would skip or materialize a new document version. DB writes
|
||||
happen only when apply=True.
|
||||
"""
|
||||
|
||||
source_rows, index_payloads = build_repo_source_pack_manifest()
|
||||
source_row_by_id = {row["source_id"]: row for row in source_rows}
|
||||
if apply:
|
||||
await _upsert_source_rows(conn, source_rows)
|
||||
|
||||
items: list[RepoSourcePackSyncItem] = []
|
||||
for payload in index_payloads:
|
||||
source_id = str(payload["source_id"])
|
||||
if source_id not in source_row_by_id:
|
||||
continue
|
||||
doc_uri = str(payload["doc_uri"])
|
||||
content_hash = str(payload["content_hash"])
|
||||
latest = await _latest_active_document(conn, source_id=source_id, doc_uri=doc_uri)
|
||||
previous_version = _latest_version(latest)
|
||||
requested_version = max(1, int(payload.get("version") or 1))
|
||||
new_version = requested_version
|
||||
if previous_version is not None:
|
||||
new_version = max(requested_version, previous_version + 1)
|
||||
|
||||
if latest and latest["content_hash"] == content_hash:
|
||||
items.append(
|
||||
RepoSourcePackSyncItem(
|
||||
source_id=source_id,
|
||||
doc_uri=doc_uri,
|
||||
previous_version=previous_version,
|
||||
new_version=previous_version or requested_version,
|
||||
content_hash=content_hash,
|
||||
doc_id=int(latest["doc_id"]),
|
||||
chunks_indexed=0,
|
||||
skipped_unchanged=True,
|
||||
embedded=False,
|
||||
degraded=False,
|
||||
applied=apply,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if not apply:
|
||||
items.append(
|
||||
RepoSourcePackSyncItem(
|
||||
source_id=source_id,
|
||||
doc_uri=doc_uri,
|
||||
previous_version=previous_version,
|
||||
new_version=new_version,
|
||||
content_hash=content_hash,
|
||||
doc_id=None,
|
||||
chunks_indexed=0,
|
||||
skipped_unchanged=False,
|
||||
embedded=False,
|
||||
degraded=False,
|
||||
applied=False,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
index_payload = dict(payload)
|
||||
index_payload["version"] = new_version
|
||||
result = await rag.index_document(conn, rag.IndexRequest(**index_payload))
|
||||
items.append(
|
||||
RepoSourcePackSyncItem(
|
||||
source_id=source_id,
|
||||
doc_uri=doc_uri,
|
||||
previous_version=previous_version,
|
||||
new_version=new_version,
|
||||
content_hash=content_hash,
|
||||
doc_id=result.doc_id,
|
||||
chunks_indexed=result.chunks_indexed,
|
||||
skipped_unchanged=result.skipped_unchanged,
|
||||
embedded=result.embedded,
|
||||
degraded=result.degraded,
|
||||
applied=True,
|
||||
)
|
||||
)
|
||||
|
||||
return RepoSourcePackSyncResult(
|
||||
sources_upserted=len(source_rows) if apply else 0,
|
||||
manifest_count=len(index_payloads),
|
||||
chunks_indexed=sum(item.chunks_indexed for item in items),
|
||||
skipped_unchanged=sum(1 for item in items if item.skipped_unchanged),
|
||||
embedded=all(item.embedded for item in items) if items else False,
|
||||
degraded=any(item.degraded for item in items),
|
||||
applied=apply,
|
||||
items=items,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RepoSourcePackSyncItem",
|
||||
"RepoSourcePackSyncResult",
|
||||
"build_repo_source_pack_manifest",
|
||||
"sync_repo_source_packs",
|
||||
]
|
||||
|
|
@ -3,8 +3,24 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from .services import live_coach
|
||||
from .services import live_coach, rag, source_pack_sync
|
||||
|
||||
|
||||
class _SourcePackConn:
|
||||
def __init__(self, latest: dict[tuple[str, str], dict[str, Any]] | None = None) -> None:
|
||||
self.latest = latest or {}
|
||||
self.execute_calls: list[tuple[str, tuple[Any, ...]]] = []
|
||||
self.fetchrow_calls: list[tuple[str, tuple[Any, ...]]] = []
|
||||
|
||||
async def execute(self, query: str, *args: Any) -> None:
|
||||
self.execute_calls.append((query, args))
|
||||
|
||||
async def fetchrow(self, query: str, *args: Any) -> dict[str, Any] | None:
|
||||
self.fetchrow_calls.append((query, args))
|
||||
return self.latest.get((str(args[0]), str(args[1])))
|
||||
|
||||
|
||||
class LiveCoachSourcePackTest(unittest.TestCase):
|
||||
|
|
@ -39,5 +55,99 @@ class LiveCoachSourcePackTest(unittest.TestCase):
|
|||
self.assertTrue(all(chunk["sensitivity"] == 2 for chunk in dsm["chunks"]))
|
||||
|
||||
|
||||
class RagIndexPolicyTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_index_document_rejects_raw_source_chunks_before_db(self) -> None:
|
||||
class NoDbConn:
|
||||
async def fetchrow(self, *_args: Any, **_kwargs: Any) -> None:
|
||||
raise AssertionError("raw source validation must run before DB access")
|
||||
|
||||
async def execute(self, *_args: Any, **_kwargs: Any) -> None:
|
||||
raise AssertionError("raw source validation must run before DB access")
|
||||
|
||||
req = rag.IndexRequest(
|
||||
source_id="raw-source",
|
||||
doc_uri="raw-source.txt",
|
||||
chunks=[
|
||||
{
|
||||
"seq": 0,
|
||||
"chunk_text": "원문 축어록",
|
||||
"visible_to": [],
|
||||
"sensitivity": 3,
|
||||
"meta": {"raw_source_artifact": True},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
with self.assertRaises(rag.IndexPolicyViolation) as caught:
|
||||
await rag.index_document(NoDbConn(), req) # type: ignore[arg-type]
|
||||
|
||||
self.assertIn("raw source artifacts must not be indexed", str(caught.exception))
|
||||
|
||||
|
||||
class RepoSourcePackSyncTest(unittest.IsolatedAsyncioTestCase):
|
||||
def _patch_manifest(self) -> Any:
|
||||
row = {
|
||||
"source_id": "source-a",
|
||||
"title": "Source A",
|
||||
"kb_kind": "theory",
|
||||
"license_class": "A",
|
||||
"origin_path": "data/source-a.json",
|
||||
"citation": "source-a",
|
||||
"external_llm_ok": True,
|
||||
}
|
||||
payload = {
|
||||
"source_id": "source-a",
|
||||
"doc_uri": "repo/source-a.json",
|
||||
"version": 1,
|
||||
"content_hash": "new-hash",
|
||||
"chunks": [{"seq": 0, "chunk_text": "body", "visible_to": ["evaluator"], "sensitivity": 2}],
|
||||
}
|
||||
return patch.object(source_pack_sync, "build_repo_source_pack_manifest", return_value=([row], [payload]))
|
||||
|
||||
async def test_dry_run_reports_next_version_without_writes(self) -> None:
|
||||
conn = _SourcePackConn({("source-a", "repo/source-a.json"): {"doc_id": 10, "version": 2, "content_hash": "old-hash"}})
|
||||
|
||||
with self._patch_manifest(), patch.object(source_pack_sync.rag, "index_document", AsyncMock()) as index_document:
|
||||
result = await source_pack_sync.sync_repo_source_packs(conn, apply=False)
|
||||
|
||||
self.assertFalse(result.applied)
|
||||
self.assertEqual(result.sources_upserted, 0)
|
||||
self.assertEqual(conn.execute_calls, [])
|
||||
index_document.assert_not_awaited()
|
||||
self.assertEqual(result.items[0].previous_version, 2)
|
||||
self.assertEqual(result.items[0].new_version, 3)
|
||||
self.assertFalse(result.items[0].skipped_unchanged)
|
||||
|
||||
async def test_apply_bumps_changed_document_version(self) -> None:
|
||||
conn = _SourcePackConn({("source-a", "repo/source-a.json"): {"doc_id": 10, "version": 2, "content_hash": "old-hash"}})
|
||||
seen_versions: list[int] = []
|
||||
|
||||
async def fake_index_document(_: Any, request: rag.IndexRequest) -> rag.IndexResult:
|
||||
seen_versions.append(request.version)
|
||||
return rag.IndexResult(doc_id=11, chunks_indexed=1, skipped_unchanged=False, embedded=False, degraded=True)
|
||||
|
||||
with self._patch_manifest(), patch.object(source_pack_sync.rag, "index_document", fake_index_document):
|
||||
result = await source_pack_sync.sync_repo_source_packs(conn, apply=True)
|
||||
|
||||
self.assertTrue(result.applied)
|
||||
self.assertEqual(result.sources_upserted, 1)
|
||||
self.assertEqual(len(conn.execute_calls), 1)
|
||||
self.assertEqual(seen_versions, [3])
|
||||
self.assertEqual(result.items[0].doc_id, 11)
|
||||
self.assertEqual(result.items[0].chunks_indexed, 1)
|
||||
self.assertTrue(result.degraded)
|
||||
|
||||
async def test_apply_skips_same_content_hash(self) -> None:
|
||||
conn = _SourcePackConn({("source-a", "repo/source-a.json"): {"doc_id": 10, "version": 2, "content_hash": "new-hash"}})
|
||||
|
||||
with self._patch_manifest(), patch.object(source_pack_sync.rag, "index_document", AsyncMock()) as index_document:
|
||||
result = await source_pack_sync.sync_repo_source_packs(conn, apply=True)
|
||||
|
||||
index_document.assert_not_awaited()
|
||||
self.assertEqual(result.skipped_unchanged, 1)
|
||||
self.assertEqual(result.items[0].doc_id, 10)
|
||||
self.assertEqual(result.items[0].new_version, 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
|
|
@ -245,6 +248,44 @@ def _draft_payload(
|
|||
|
||||
|
||||
class PersonaApprovalBoundaryTest(unittest.IsolatedAsyncioTestCase):
|
||||
def test_persona_seed_runner_reports_repo_manifest_without_db(self) -> None:
|
||||
script_path = Path(__file__).resolve().parents[3] / "scripts" / "materialize-persona-seeds.py"
|
||||
spec = importlib.util.spec_from_file_location("materialize_persona_seeds", script_path)
|
||||
self.assertIsNotNone(spec)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
rows = module._manifest_rows()
|
||||
|
||||
self.assertEqual([row["code"] for row in rows], ["P1", "P2", "P3", "P4", "P5", "P6", "P7"])
|
||||
self.assertEqual({row["version"] for row in rows}, {persona_repository.SEED_VERSION})
|
||||
self.assertEqual(rows[0]["persona_id"], str(persona_repository.seed_persona_id("P1")))
|
||||
self.assertIn("source_provenance", rows[0])
|
||||
|
||||
async def test_persona_seed_runner_apply_initializes_and_closes_pool(self) -> None:
|
||||
script_path = Path(__file__).resolve().parents[3] / "scripts" / "materialize-persona-seeds.py"
|
||||
spec = importlib.util.spec_from_file_location("materialize_persona_seeds_apply", script_path)
|
||||
self.assertIsNotNone(spec)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
init_pool = AsyncMock()
|
||||
materialize = AsyncMock(return_value=7)
|
||||
close_pool = AsyncMock()
|
||||
with (
|
||||
patch.object(module, "init_pool", init_pool),
|
||||
patch.object(module, "materialize_seed_personas", materialize),
|
||||
patch.object(module, "close_pool", close_pool),
|
||||
):
|
||||
exit_code = await module._main_async(module.parse_args(["--apply"]))
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
init_pool.assert_awaited_once()
|
||||
materialize.assert_awaited_once()
|
||||
close_pool.assert_awaited_once()
|
||||
|
||||
async def test_repository_loads_repo_persona_files_for_p4_to_p7(self) -> None:
|
||||
cards = persona_repository.load_file_personas()
|
||||
|
||||
|
|
@ -753,12 +794,16 @@ class PersonaReviewQueueTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(response.draft.code, "P8")
|
||||
self.assertEqual(response.draft.triggers["sore_spots"], ["버림받음"])
|
||||
self.assertEqual(response.source_summary, "관계 상실 후 불안이 높아진 사례")
|
||||
self.assertEqual(response.pii_entities_masked, ["PHONE"])
|
||||
self.assertIn("PHONE", response.pii_entities_masked)
|
||||
self.assertEqual(response.source_references[0].source_id, "persona_authoring_test")
|
||||
self.assertEqual(response.evidence_chunks[0].chunk_id, 44)
|
||||
self.assertIn("RAG sources=persona_authoring_test", response.draft.source_provenance)
|
||||
self.assertIn("chunks=44", response.draft.source_provenance)
|
||||
self.assertIn("prompt=persona-draft-rag@2026-06-28.1#", response.draft.source_provenance)
|
||||
self.assertEqual(len(captured), 1)
|
||||
self.assertEqual(captured[0].metadata["prompt_bundle"]["id"], "persona-draft-rag")
|
||||
self.assertEqual(captured[0].metadata["prompt_bundle"]["version"], "2026-06-28.1")
|
||||
self.assertRegex(captured[0].metadata["prompt_bundle"]["hash"], r"^[0-9a-f]{12}$")
|
||||
sent_text = captured[0].messages[-1].content
|
||||
self.assertNotIn("010-1234-5678", sent_text)
|
||||
self.assertIn("[PHONE]", sent_text)
|
||||
|
|
@ -799,7 +844,7 @@ class PersonaReviewQueueTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(response.kb_kind, "diagnostic")
|
||||
self.assertEqual(response.license_class, "B")
|
||||
self.assertTrue(response.external_llm_ok)
|
||||
self.assertEqual(response.pii_entities_masked, ["PHONE"])
|
||||
self.assertIn("PHONE", response.pii_entities_masked)
|
||||
self.assertEqual(len(captured_index), 1)
|
||||
index_req = captured_index[0]
|
||||
self.assertTrue(index_req.source_id.startswith("persona_authoring_"))
|
||||
|
|
@ -808,9 +853,24 @@ class PersonaReviewQueueTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(index_req.chunks[0]["visible_to"], ["evaluator"])
|
||||
self.assertEqual(index_req.chunks[0]["sensitivity"], 2)
|
||||
self.assertEqual(index_req.chunks[0]["meta"]["source_kind"], "client_record")
|
||||
self.assertTrue(index_req.chunks[0]["meta"]["raw_source_not_indexed"])
|
||||
self.assertEqual(index_req.chunks[0]["meta"]["raw_source_storage"], "hash_only")
|
||||
self.assertTrue(index_req.chunks[0]["meta"]["raw_source_id"].endswith("_raw"))
|
||||
self.assertRegex(index_req.chunks[0]["meta"]["raw_source_content_hash"], r"^[0-9a-f]{64}$")
|
||||
self.assertNotIn("raw_source_not_stored", index_req.chunks[0]["meta"])
|
||||
source_query, source_args = conn.execute_calls[0]
|
||||
self.assertIn("INSERT INTO kb.source", source_query)
|
||||
self.assertEqual(source_args[2], "diagnostic")
|
||||
raw_query, raw_args = conn.execute_calls[1]
|
||||
self.assertIn("INSERT INTO kb.raw_source_artifact", raw_query)
|
||||
self.assertEqual(raw_args[0], index_req.chunks[0]["meta"]["raw_source_id"])
|
||||
self.assertEqual(raw_args[1], index_req.source_id)
|
||||
self.assertEqual(raw_args[2], "00000000-0000-0000-0000-000000000901")
|
||||
self.assertEqual(raw_args[5], "B")
|
||||
raw_summary = json.loads(raw_args[6])
|
||||
self.assertEqual(raw_summary["derived_source_id"], index_req.source_id)
|
||||
self.assertTrue(raw_summary["raw_text_not_indexed"])
|
||||
self.assertEqual(raw_summary["storage"], "hash_only")
|
||||
|
||||
async def test_learner_cannot_create_persona_draft_route(self) -> None:
|
||||
with patch.object(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue