페르소나 소스팩 동기화 정리

This commit is contained in:
Yun Chan 2026-06-28 20:12:35 +09:00
parent 6a81ec596c
commit e8e08935ed
10 changed files with 1126 additions and 283 deletions

View file

@ -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
],
)

View file

@ -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)