라이브코치 소스팩 새로고침 보강

This commit is contained in:
Yun Chan 2026-06-28 23:52:55 +09:00
parent 84599bbaa2
commit 1e9f293fda
5 changed files with 182 additions and 8 deletions

View file

@ -356,7 +356,7 @@ async def sync_live_coach_source_packs(
evaluator RAG 검색에도 올린다. source row를 먼저 upsert한 content_hash 기반 증분 색인을 evaluator RAG 검색에도 올린다. source row를 먼저 upsert한 content_hash 기반 증분 색인을
수행한다. 임베딩 모델 미가용 BM25-only degraded 색인으로 이어진다. 수행한다. 임베딩 모델 미가용 BM25-only degraded 색인으로 이어진다.
""" """
source_rows, index_payloads = source_pack_sync.build_repo_source_pack_manifest() source_rows, index_payloads = source_pack_sync.build_repo_source_pack_manifest(refresh=True)
if not source_rows or not index_payloads: if not source_rows or not index_payloads:
raise HTTPException( raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY, status.HTTP_422_UNPROCESSABLE_ENTITY,
@ -365,7 +365,7 @@ async def sync_live_coach_source_packs(
try: try:
async with acquire() as conn: async with acquire() as conn:
result = await source_pack_sync.sync_repo_source_packs(conn, apply=True) result = await source_pack_sync.sync_repo_source_packs(conn, apply=True, refresh=True)
except rag.NotConfigured as exc: except rag.NotConfigured as exc:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"RAG not configured: {exc}") from exc raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"RAG not configured: {exc}") from exc
except RuntimeError as exc: except RuntimeError as exc:

View file

@ -18,11 +18,12 @@ import hashlib
from functools import lru_cache from functools import lru_cache
from typing import TYPE_CHECKING, Any, Literal, Optional from typing import TYPE_CHECKING, Any, Literal, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, field_validator
from ..config import settings from ..config import settings
from ..engine_client import EngineClient, EngineError, EngineMessage, GenerateRequest, GenerateResponse from ..engine_client import EngineClient, EngineError, EngineMessage, GenerateRequest, GenerateResponse
from ..paths import repo_root, repo_path from ..paths import repo_root, repo_path
from ..session_read_model import StageLabel, stage_label_or_none
from . import guardrail from . import guardrail
if TYPE_CHECKING: if TYPE_CHECKING:
@ -77,12 +78,17 @@ class LiveCoachEvent(BaseModel):
event_id: str event_id: str
session_id: str session_id: str
turn_seq: int turn_seq: int
stage: str stage: StageLabel | None = None
created_at: str created_at: str
learner_text_excerpt: Optional[str] = None learner_text_excerpt: Optional[str] = None
client_reply_excerpt: Optional[str] = None client_reply_excerpt: Optional[str] = None
suggestion: LiveCoachSuggestion suggestion: LiveCoachSuggestion
@field_validator("stage", mode="before")
@classmethod
def _normalize_stage(cls, value: object) -> StageLabel | None:
return stage_label_or_none(value)
class LiveCoachInput(BaseModel): class LiveCoachInput(BaseModel):
"""라이브 코칭 입력. raw text는 서비스 내부에서 마스킹 후 프롬프트에 쓴다.""" """라이브 코칭 입력. raw text는 서비스 내부에서 마스킹 후 프롬프트에 쓴다."""
@ -126,6 +132,8 @@ _ALLOWED_KB_KINDS = {
"ko_context", "ko_context",
"microskill", "microskill",
} }
LOCAL_SOURCE_PACK_CACHE_KEY = "repo:data/kb/live_coaching_workbook_0615.json+data/kb/live_coaching_sources/*.json"
LOCAL_SOURCE_PACK_CACHE_LIFETIME = "api-process"
def _configured_model(value: str | None) -> str | None: def _configured_model(value: str | None) -> str | None:
@ -135,6 +143,13 @@ def _configured_model(value: str | None) -> str | None:
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
def _local_source_entries() -> tuple[tuple[str, dict[str, Any]], ...]: def _local_source_entries() -> tuple[tuple[str, dict[str, Any]], ...]:
"""Load repo-managed live-coach source packs.
Cache ownership: live turn generation keeps this process-local snapshot to
avoid per-turn file IO. Admin/CLI source-pack sync is the invalidation
boundary because that path explicitly compares repo files with kb.document.
"""
entries: list[tuple[str, dict[str, Any]]] = [] entries: list[tuple[str, dict[str, Any]]] = []
paths = [_WORKBOOK_PATH] paths = [_WORKBOOK_PATH]
if _SOURCE_DIR.exists(): if _SOURCE_DIR.exists():
@ -164,6 +179,12 @@ def _local_source_entries() -> tuple[tuple[str, dict[str, Any]], ...]:
) )
def clear_local_source_pack_cache() -> None:
"""Invalidate the process-local source pack snapshot for admin/CLI refresh."""
_local_source_entries.cache_clear()
def _local_source_payloads() -> list[dict[str, Any]]: def _local_source_payloads() -> list[dict[str, Any]]:
return [payload for _, payload in _local_source_entries()] return [payload for _, payload in _local_source_entries()]
@ -673,5 +694,6 @@ __all__ = [
"LiveCoachInput", "LiveCoachInput",
"LiveCoachSource", "LiveCoachSource",
"LiveCoachSuggestion", "LiveCoachSuggestion",
"clear_local_source_pack_cache",
"generate_live_coaching", "generate_live_coaching",
] ]

View file

@ -40,9 +40,11 @@ class RepoSourcePackSyncResult:
items: list[RepoSourcePackSyncItem] items: list[RepoSourcePackSyncItem]
def build_repo_source_pack_manifest() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: def build_repo_source_pack_manifest(*, refresh: bool = False) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Return repo-managed source rows and RAG index payloads without DB access.""" """Return repo-managed source rows and RAG index payloads without DB access."""
if refresh:
live_coach.clear_local_source_pack_cache()
return live_coach.build_rag_source_rows(), live_coach.build_rag_index_payloads() return live_coach.build_rag_source_rows(), live_coach.build_rag_index_payloads()
@ -92,7 +94,12 @@ async def _upsert_source_rows(conn: Any, source_rows: list[dict[str, Any]]) -> N
) )
async def sync_repo_source_packs(conn: Any, *, apply: bool = False) -> RepoSourcePackSyncResult: async def sync_repo_source_packs(
conn: Any,
*,
apply: bool = False,
refresh: bool = False,
) -> RepoSourcePackSyncResult:
"""Compare or apply repo-managed source packs against kb.document. """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 Dry-run mode still reads the active DB document row so it can report whether
@ -100,7 +107,7 @@ async def sync_repo_source_packs(conn: Any, *, apply: bool = False) -> RepoSourc
happen only when apply=True. happen only when apply=True.
""" """
source_rows, index_payloads = build_repo_source_pack_manifest() source_rows, index_payloads = build_repo_source_pack_manifest(refresh=refresh)
source_row_by_id = {row["source_id"]: row for row in source_rows} source_row_by_id = {row["source_id"]: row for row in source_rows}
if apply: if apply:
await _upsert_source_rows(conn, source_rows) await _upsert_source_rows(conn, source_rows)

View file

@ -2,7 +2,10 @@
from __future__ import annotations from __future__ import annotations
import json
import tempfile
import unittest import unittest
from pathlib import Path
from typing import Any from typing import Any
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
@ -24,6 +27,59 @@ class _SourcePackConn:
class LiveCoachSourcePackTest(unittest.TestCase): class LiveCoachSourcePackTest(unittest.TestCase):
def test_manifest_refresh_reads_changed_repo_source_pack_after_cache_warm(self) -> None:
with tempfile.TemporaryDirectory() as temp_root:
root = Path(temp_root)
workbook_path = root / "live_coaching_workbook_0615.json"
source_dir = root / "live_coaching_sources"
source_dir.mkdir()
def write_workbook(summary: str) -> None:
workbook_path.write_text(
json.dumps(
{
"source": {
"source_id": "source-a",
"title": "Source A",
"kb_kind": "theory",
"license_class": "A",
"citation": "source-a",
"external_llm_ok": True,
},
"chunks": [
{
"id": "chunk-a",
"summary": summary,
"citation": "source-a",
"visible_to": ["evaluator"],
}
],
},
ensure_ascii=False,
),
encoding="utf-8",
)
with (
patch.object(live_coach, "_REPO_ROOT", root),
patch.object(live_coach, "_WORKBOOK_PATH", workbook_path),
patch.object(live_coach, "_SOURCE_DIR", source_dir),
):
live_coach.clear_local_source_pack_cache()
write_workbook("first source pack summary")
_, first_payloads = source_pack_sync.build_repo_source_pack_manifest(refresh=True)
first_hash = first_payloads[0]["content_hash"]
write_workbook("changed source pack summary")
_, stale_payloads = source_pack_sync.build_repo_source_pack_manifest()
self.assertEqual(stale_payloads[0]["content_hash"], first_hash)
_, refreshed_payloads = source_pack_sync.build_repo_source_pack_manifest(refresh=True)
self.assertNotEqual(refreshed_payloads[0]["content_hash"], first_hash)
self.assertEqual(refreshed_payloads[0]["chunks"][0]["chunk_text"], "changed source pack summary")
live_coach.clear_local_source_pack_cache()
def test_source_packs_build_rag_rows_and_index_payloads(self) -> None: def test_source_packs_build_rag_rows_and_index_payloads(self) -> None:
rows = live_coach.build_rag_source_rows() rows = live_coach.build_rag_source_rows()
payloads = live_coach.build_rag_index_payloads() payloads = live_coach.build_rag_index_payloads()
@ -85,6 +141,91 @@ class RagIndexPolicyTest(unittest.IsolatedAsyncioTestCase):
class RepoSourcePackSyncTest(unittest.IsolatedAsyncioTestCase): class RepoSourcePackSyncTest(unittest.IsolatedAsyncioTestCase):
async def test_sync_refresh_after_file_change_reports_next_document_version(self) -> None:
with tempfile.TemporaryDirectory() as temp_root:
root = Path(temp_root)
workbook_path = root / "live_coaching_workbook_0615.json"
source_dir = root / "live_coaching_sources"
source_dir.mkdir()
def write_workbook(summary: str) -> None:
workbook_path.write_text(
json.dumps(
{
"source": {
"source_id": "source-a",
"title": "Source A",
"kb_kind": "theory",
"license_class": "A",
"citation": "source-a",
"external_llm_ok": True,
},
"chunks": [
{
"id": "chunk-a",
"summary": summary,
"citation": "source-a",
"visible_to": ["evaluator"],
}
],
},
ensure_ascii=False,
),
encoding="utf-8",
)
with (
patch.object(live_coach, "_REPO_ROOT", root),
patch.object(live_coach, "_WORKBOOK_PATH", workbook_path),
patch.object(live_coach, "_SOURCE_DIR", source_dir),
):
live_coach.clear_local_source_pack_cache()
write_workbook("first source pack summary")
_, first_payloads = source_pack_sync.build_repo_source_pack_manifest(refresh=True)
first_payload = first_payloads[0]
write_workbook("changed source pack summary")
conn = _SourcePackConn(
{
("source-a", first_payload["doc_uri"]): {
"doc_id": 10,
"version": 2,
"content_hash": first_payload["content_hash"],
}
}
)
result = await source_pack_sync.sync_repo_source_packs(conn, apply=False, refresh=True)
self.assertFalse(result.applied)
self.assertEqual(result.items[0].previous_version, 2)
self.assertEqual(result.items[0].new_version, 3)
self.assertFalse(result.items[0].skipped_unchanged)
self.assertNotEqual(result.items[0].content_hash, first_payload["content_hash"])
live_coach.clear_local_source_pack_cache()
def test_manifest_refresh_invalidates_live_coach_source_cache(self) -> None:
with (
patch.object(source_pack_sync.live_coach, "clear_local_source_pack_cache") as clear_cache,
patch.object(source_pack_sync.live_coach, "build_rag_source_rows", return_value=[]) as build_rows,
patch.object(source_pack_sync.live_coach, "build_rag_index_payloads", return_value=[]) as build_payloads,
):
source_pack_sync.build_repo_source_pack_manifest(refresh=True)
clear_cache.assert_called_once_with()
build_rows.assert_called_once_with()
build_payloads.assert_called_once_with()
def test_manifest_default_reuses_live_coach_source_cache(self) -> None:
with (
patch.object(source_pack_sync.live_coach, "clear_local_source_pack_cache") as clear_cache,
patch.object(source_pack_sync.live_coach, "build_rag_source_rows", return_value=[]),
patch.object(source_pack_sync.live_coach, "build_rag_index_payloads", return_value=[]),
):
source_pack_sync.build_repo_source_pack_manifest()
clear_cache.assert_not_called()
def _patch_manifest(self) -> Any: def _patch_manifest(self) -> Any:
row = { row = {
"source_id": "source-a", "source_id": "source-a",

View file

@ -59,7 +59,11 @@ async def _main_async(args: argparse.Namespace) -> int:
await init_pool() await init_pool()
try: try:
async with acquire(role="admin") as conn: async with acquire(role="admin") as conn:
result = await source_pack_sync.sync_repo_source_packs(conn, apply=bool(args.apply)) result = await source_pack_sync.sync_repo_source_packs(
conn,
apply=bool(args.apply),
refresh=True,
)
finally: finally:
await close_pool() await close_pool()
if args.json: if args.json: