페르소나 소스팩 동기화 정리
This commit is contained in:
parent
6a81ec596c
commit
e8e08935ed
10 changed files with 1126 additions and 283 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue