From 428f37e373710b573d3ed1e55593e02a82aa8d64 Mon Sep 17 00:00:00 2001 From: HOKAGO-MEMORIES Date: Thu, 25 Jun 2026 02:18:24 +0900 Subject: [PATCH 1/2] =?UTF-8?q?test(ai):=20=EC=95=88=EC=A0=84=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EA=B2=BD=EB=A1=9C=20=EA=B2=80=EC=A6=9D=20=EB=B0=8F?= =?UTF-8?q?=20=EB=AC=B8=EC=84=9C=ED=99=94=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-ai/app/clients/llm_client.py | 3 + backend-ai/app/clients/spring_client.py | 2 +- backend-ai/app/clients/supabase_client.py | 12 ++- backend-ai/app/graph/state.py | 12 +++ .../app/services/analysis_answer_service.py | 8 +- backend-ai/tests/test_agent_chat.py | 10 +-- .../tests/test_analysis_answer_generation.py | 14 ++-- backend-ai/tests/test_safety_analysis.py | 79 +++++++++++++++++++ backend-ai/tests/test_spring_client.py | 6 +- docs/06_EXTERNAL_APIS.md | 18 +++++ docs/07_DOMAIN_MODEL.md | 19 +++++ docs/08_API_SPEC.md | 13 +++ docs/09_BATCH_INGESTION.md | 37 +++++++++ docs/11_ROADMAP.md | 13 +++ 14 files changed, 225 insertions(+), 21 deletions(-) create mode 100644 backend-ai/tests/test_safety_analysis.py diff --git a/backend-ai/app/clients/llm_client.py b/backend-ai/app/clients/llm_client.py index c0421b2..793d602 100644 --- a/backend-ai/app/clients/llm_client.py +++ b/backend-ai/app/clients/llm_client.py @@ -179,6 +179,9 @@ def extract_property_criteria(self, message: str) -> dict[str, Any]: def generate_answer(self, state: AgentState) -> str: workers_called = state.get("workers_called", []) + if not workers_called and state.get("intent"): + intent = state["intent"] + workers_called = [str(getattr(intent, "value", intent))] if "GENERAL_CHAT" in workers_called: live = self._generate_live_general_chat_answer(state) diff --git a/backend-ai/app/clients/spring_client.py b/backend-ai/app/clients/spring_client.py index 5b2f298..92547d3 100644 --- a/backend-ai/app/clients/spring_client.py +++ b/backend-ai/app/clients/spring_client.py @@ -71,7 +71,7 @@ def analyze_price(self, message: str, context: dict[str, Any]) -> dict[str, Any] "query": message, "selectedPropertyId": selected_property_id, "summary": ( - f"{building_name} 기준으로 최근 실거래 {len(transaction_items)}건과 " + f"{building_name} 기준으로 최근 거래 {len(transaction_items)}건과 " f"지역 통계 {len(region_stats)}건을 확인했습니다." ), "property": property_detail, diff --git a/backend-ai/app/clients/supabase_client.py b/backend-ai/app/clients/supabase_client.py index 12ce8dd..9af3e9f 100644 --- a/backend-ai/app/clients/supabase_client.py +++ b/backend-ai/app/clients/supabase_client.py @@ -77,11 +77,14 @@ def _similarity_search_legal_documents_pgvector( connect_timeout=self.connect_timeout_seconds, ) as conn: with conn.cursor() as cursor: - cursor.execute(f"SET LOCAL statement_timeout = {int(self.statement_timeout_ms)}") + cursor.execute( + "set local statement_timeout = %s", + (int(self.statement_timeout_ms),), + ) # IVFFlat 인덱스가 lists=100으로 설정돼 있으나 데이터 수가 적을 때 # 기본 probes=1이면 대부분의 클러스터를 건너뛰어 결과가 0개가 됨. # probes를 lists 값과 동일하게 설정해 전체 인덱스를 탐색하도록 한다. - cursor.execute("SET LOCAL ivfflat.probes = 100") + cursor.execute("set local ivfflat.probes = %s", (100,)) cursor.execute(sql, (vector_literal, vector_literal, top_k)) return list(cursor.fetchall()) @@ -128,7 +131,10 @@ def search_properties(self, criteria: dict[str, Any], limit: int = 20) -> list[d connect_timeout=self.connect_timeout_seconds, ) as conn: with conn.cursor() as cursor: - cursor.execute(f"SET LOCAL statement_timeout = {int(self.statement_timeout_ms)}") + cursor.execute( + "set local statement_timeout = %s", + (int(self.statement_timeout_ms),), + ) cursor.execute(sql, params) return list(cursor.fetchall()) diff --git a/backend-ai/app/graph/state.py b/backend-ai/app/graph/state.py index 923caf0..7947598 100644 --- a/backend-ai/app/graph/state.py +++ b/backend-ai/app/graph/state.py @@ -1,11 +1,23 @@ +from enum import StrEnum from typing import Any, NotRequired, TypedDict +class Intent(StrEnum): + PROPERTY_SEARCH = "PROPERTY_SEARCH" + LEGAL_CONSULT = "LEGAL_CONSULT" + PRICE_ANALYSIS = "PRICE_ANALYSIS" + SAFETY_ANALYSIS = "SAFETY_ANALYSIS" + HUG_CALC = "HUG_CALC" + GENERAL_CHAT = "GENERAL_CHAT" + FINISH = "FINISH" + + class AgentState(TypedDict): user_id: str session_id: str | None message: str context: dict[str, Any] + intent: NotRequired[str] next_worker: NotRequired[str] workers_called: NotRequired[list[str]] answer: NotRequired[str] diff --git a/backend-ai/app/services/analysis_answer_service.py b/backend-ai/app/services/analysis_answer_service.py index e30ebe5..874d163 100644 --- a/backend-ai/app/services/analysis_answer_service.py +++ b/backend-ai/app/services/analysis_answer_service.py @@ -23,7 +23,7 @@ def generate_price_answer(self, state: AgentState) -> str: else: comparable_count = None if comparable_count is not None: - facts.append(f"최근 실거래 {comparable_count}건을 기준으로 확인했습니다.") + facts.append(f"최근 거래 {comparable_count}건을 기준으로 확인했습니다.") if transactions: transaction = transactions[0] @@ -41,7 +41,7 @@ def generate_price_answer(self, state: AgentState) -> str: if area_m2 is not None: parts.append(f"전용면적 {area_m2}㎡") if parts: - facts.append("최근 사례는 " + ", ".join(parts) + "입니다.") + facts.append("최근 거래는 " + ", ".join(parts) + "입니다.") if region_stats: region_stat = region_stats[0] @@ -61,7 +61,7 @@ def generate_price_answer(self, state: AgentState) -> str: if not facts: return "분석할 근거 데이터가 부족합니다. 매물을 선택하거나 시세 데이터가 쌓인 뒤 다시 확인해 주세요." - return " ".join(facts) + " 보증보험 가능 여부나 법적 판단은 포함하지 않습니다." + return " ".join(facts) + " 보증보험 가능 여부나 법적 판단은 포함하지 않았습니다." def generate_safety_answer(self, state: AgentState) -> str: result = self._tool_result(state, "safetyAnalysis") @@ -97,7 +97,7 @@ def generate_safety_answer(self, state: AgentState) -> str: ("cctvCount300m", "CCTV"), ("bellCount300m", "비상벨"), ("lightCount300m", "보안등"), - ("policeCount500m", "파출소"), + ("policeCount500m", "경찰시설"), ] for key, label in count_specs: value = metrics.get(key) diff --git a/backend-ai/tests/test_agent_chat.py b/backend-ai/tests/test_agent_chat.py index 949940a..1135e12 100644 --- a/backend-ai/tests/test_agent_chat.py +++ b/backend-ai/tests/test_agent_chat.py @@ -17,7 +17,7 @@ def internal_api_headers() -> dict[str, str]: def route_as(monkeypatch, *workers: str) -> None: - """supervisor가 지정된 워커들을 순서대로 호출하고 FINISH하도록 mock.""" + """Mock supervisor routing so workers are called in the given order.""" call_count = {"n": 0} worker_list = list(workers) @@ -113,7 +113,7 @@ class FakeSpringClient: def analyze_price(self, message: str, context: dict) -> dict: return { "selectedPropertyId": context["selectedPropertyId"], - "summary": "최근 실거래와 지역 통계를 확인했습니다.", + "summary": "최근 거래와 지역 통계를 확인했습니다.", "metrics": { "comparableTransactionCount": 2, "regionStatCount": 1, @@ -165,7 +165,7 @@ def analyze_price(self, message: str, context: dict) -> dict: assert card["metrics"]["selectedPropertyId"] == "1" assert card["metrics"]["comparableTransactionCount"] == 2 assert card["metrics"]["stub"] is False - assert "최근 실거래 2건" in body["answer"] + assert "최근 거래 2건" in body["answer"] assert "지역 평균 보증금 10,500,000원" in body["answer"] @@ -247,8 +247,8 @@ def analyze_safety(self, message: str, context: dict) -> dict: assert body["analysisCards"] card = body["analysisCards"][0] assert card["type"] == "SAFETY" - assert card["title"] - assert card["summary"] + assert card["title"] == "안전 분석" + assert card["summary"] == "반경 500m 기준 안전 점수는 78점입니다." assert card["metrics"]["selectedPropertyId"] == "1" assert card["score"] == 78 assert card["metrics"]["radius"] == 500 diff --git a/backend-ai/tests/test_analysis_answer_generation.py b/backend-ai/tests/test_analysis_answer_generation.py index ee1a69c..1f5de66 100644 --- a/backend-ai/tests/test_analysis_answer_generation.py +++ b/backend-ai/tests/test_analysis_answer_generation.py @@ -13,7 +13,7 @@ def test_llm_client_generates_price_answer_from_tool_results() -> None: "analysis_cards": [ { "type": "PRICE", - "summary": "최근 실거래와 지역 통계를 확인했습니다.", + "summary": "최근 거래와 지역 통계를 확인했습니다.", "metrics": { "selectedPropertyId": "1", "comparableTransactionCount": 2, @@ -52,7 +52,7 @@ def test_llm_client_generates_price_answer_from_tool_results() -> None: } ) - assert "최근 실거래 2건" in answer + assert "최근 거래 2건" in answer assert "2026-05" in answer assert "보증금 10,000,000원" in answer assert "월세 520,000원" in answer @@ -105,7 +105,7 @@ def test_llm_client_generates_safety_answer_from_tool_results() -> None: assert "CCTV 8개" in answer assert "비상벨 2개" in answer assert "보안등 14개" in answer - assert "파출소 1개" in answer + assert "경찰시설 1개" in answer assert "확정" not in answer @@ -176,7 +176,7 @@ def test_llm_client_price_answer_preserves_zero_comparable_count() -> None: { "user_id": "user-1", "session_id": None, - "message": "최근 거래가 있어?", + "message": "최근 거래가 없어?", "context": {"selectedPropertyId": "1"}, "intent": Intent.PRICE_ANALYSIS, "analysis_cards": [ @@ -204,9 +204,9 @@ def test_llm_client_price_answer_preserves_zero_comparable_count() -> None: } ) - assert "최근 실거래 0건" in answer - assert "최근 실거래 1건" not in answer - assert "최근 실거래 7건" not in answer + assert "최근 거래 0건" in answer + assert "최근 거래 1건" not in answer + assert "최근 거래 7건" not in answer assert "2026-05" in answer diff --git a/backend-ai/tests/test_safety_analysis.py b/backend-ai/tests/test_safety_analysis.py new file mode 100644 index 0000000..2efb140 --- /dev/null +++ b/backend-ai/tests/test_safety_analysis.py @@ -0,0 +1,79 @@ +from app.graph.nodes import safety_analysis as safety_analysis_module +from app.graph.nodes.safety_analysis import safety_analysis +from app.graph.state import Intent +from app.services.analysis_answer_service import AnalysisAnswerService + + +def test_safety_analysis_uses_precomputed_spring_summary(monkeypatch) -> None: + class FakeSpringClient: + def analyze_safety(self, message: str, context: dict) -> dict: + assert message == "이 매물 주변 안전은 어때?" + assert context["selectedPropertyId"] == "7" + return { + "selectedPropertyId": "7", + "summary": "반경 500m 기준 안전 점수는 78점입니다.", + "score": 78, + "metrics": { + "radius": 500, + "cctvCount300m": 8, + "bellCount300m": 2, + "lightCount300m": 14, + "policeCount500m": 1, + }, + "safetySummary": { + "propertyId": 7, + "radius": 500, + "safetyScore": 78, + "cctvCount300m": 8, + "bellCount300m": 2, + "lightCount300m": 14, + "policeCount500m": 1, + }, + "stub": False, + } + + monkeypatch.setattr(safety_analysis_module, "SpringClient", FakeSpringClient) + + state = safety_analysis( + { + "user_id": "user-1", + "session_id": None, + "message": "이 매물 주변 안전은 어때?", + "context": {"selectedPropertyId": "7"}, + "intent": Intent.SAFETY_ANALYSIS, + "workers_called": [], + "analysis_cards": [], + "tool_results": {}, + } + ) + + card = state["analysis_cards"][0] + assert card["type"] == "SAFETY" + assert card["title"] == "안전 분석" + assert card["summary"] == "반경 500m 기준 안전 점수는 78점입니다." + assert card["score"] == 78 + assert card["metrics"]["radius"] == 500 + assert card["metrics"]["cctvCount300m"] == 8 + assert state["tool_results"]["safetyAnalysis"]["safetySummary"]["safetyScore"] == 78 + + answer = AnalysisAnswerService().generate_safety_answer(state) + assert "안전 점수 78점" in answer + assert "비상벨 2개" in answer + assert "보안등 14개" in answer + assert "경찰시설 1개" in answer + + +def test_safety_answer_handles_missing_summary() -> None: + answer = AnalysisAnswerService().generate_safety_answer( + { + "user_id": "user-1", + "session_id": None, + "message": "안전 분석해줘", + "context": {}, + "intent": Intent.SAFETY_ANALYSIS, + "analysis_cards": [], + "tool_results": {"safetyAnalysis": {"metrics": {}}}, + } + ) + + assert answer == "분석할 근거 데이터가 부족합니다. 매물을 선택하거나 안전 데이터가 쌓인 뒤 다시 확인해 주세요." diff --git a/backend-ai/tests/test_spring_client.py b/backend-ai/tests/test_spring_client.py index 20f11aa..ed3616d 100644 --- a/backend-ai/tests/test_spring_client.py +++ b/backend-ai/tests/test_spring_client.py @@ -49,6 +49,7 @@ def fake_get(url: str, params: dict[str, Any] | None = None, timeout: float = 0) assert result["selectedPropertyId"] == "7" assert result["stub"] is False + assert result["summary"] == "Green Villa 기준으로 최근 거래 1건과 지역 통계 1건을 확인했습니다." assert result["metrics"]["comparableTransactionCount"] == 1 assert result["metrics"]["regionStatCount"] == 1 assert result["metrics"]["buildingStatCount"] == 1 @@ -90,6 +91,8 @@ def fake_get(url: str, params: dict[str, Any] | None = None, timeout: float = 0) assert result["selectedPropertyId"] == "7" assert result["score"] == 78 + assert result["summary"] == "반경 500m 기준 안전 점수는 78점입니다." + assert result["safetySummary"]["safetyScore"] == 78 assert result["metrics"]["radius"] == 500 assert result["metrics"]["cctvCount300m"] == 8 assert calls == [ @@ -110,6 +113,7 @@ def fake_get(url: str, **kwargs: Any) -> FakeResponse: assert result["requiresSelection"] is True assert result["selectedPropertyId"] is None + assert result["summary"] == "분석할 매물을 먼저 선택해 주세요." assert calls == [] @@ -124,4 +128,4 @@ def fake_get(url: str, **kwargs: Any) -> FakeResponse: assert result["selectedPropertyId"] == "7" assert result["error"] == "SPRING_API_UNAVAILABLE" assert result["stub"] is False - assert result["summary"] + assert result["summary"] == "선택한 매물의 안전 데이터를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요." diff --git a/docs/06_EXTERNAL_APIS.md b/docs/06_EXTERNAL_APIS.md index 2cca2f7..cafd35c 100644 --- a/docs/06_EXTERNAL_APIS.md +++ b/docs/06_EXTERNAL_APIS.md @@ -139,3 +139,21 @@ instead of calling public APIs during user requests. | SafetyMap police facility IF_0036 | `POLICE` | `safety.data.safemap-police-url`, default `https://www.safemap.go.kr/openapi2/IF_0036` | XML | Uses `safety.data.safemap-service-key` or `SAFEMAP_SERVICE_KEY`; `x` is longitude and `y` is latitude. | Common paging config: `safety.data.page-size` defaults to `1000`. + +## F-4 Safety API Operations + +The F-4 MVP uses public safety APIs only in backend batch jobs. Runtime user requests read Spring Boot DB-backed APIs only. + +| Data | Public source format | Stored table | Runtime use | +| --- | --- | --- | --- | +| CCTV | CSV | `safety_facility` | Map overlay and safety score count within 300m. | +| Emergency bell | JSON or XML depending on configured endpoint | `safety_facility` | Safety score count within 300m. | +| Security light | JSON | `safety_facility` | Safety score count within 300m. | +| Police/security facility | SafetyMap XML | `safety_facility` | Safety score count within 500m. | + +Operators must configure service keys as environment variables: + +- `PUBLIC_DATA_SERVICE_KEY` for public-data endpoints that require a service key. +- `SAFEMAP_SERVICE_KEY` for the SafetyMap police/security facility source. + +Do not expose these keys to the frontend. The frontend and backend-ai call Spring Boot APIs only. WMS-based safety layers are not part of the MVP stored-data flow. diff --git a/docs/07_DOMAIN_MODEL.md b/docs/07_DOMAIN_MODEL.md index 1cfe789..8dd1c64 100644 --- a/docs/07_DOMAIN_MODEL.md +++ b/docs/07_DOMAIN_MODEL.md @@ -364,3 +364,22 @@ F-1 MVP 생성 매물은 `properties`에 다음 기준으로 저장합니다. - 기존 DB 데이터는 삭제하지 않고 upsert raw XML, JSONL, geocoding cache, SQL chunk 같은 파일은 로컬 파이프라인 산출물입니다. git에 커밋하지 않으며, 운영 수집 job이 같은 역할을 대체하면 삭제해도 됩니다. + +## F-4 Safety Score Stored Model + +`SafetyFacility` stores normalized point data from monthly safety facility ingestion. The MVP score flow uses these stored points only; it does not call public APIs during map, detail, chat, or AI analysis requests. + +`PropertyScoreStat` stores precomputed per-property score data: + +| Field | Meaning | +| --- | --- | +| `property_id` | Target property ID. | +| `safety_score` | Rounded weighted score from CCTV, emergency bell, security light, and police/security facility counts. | +| `price_score` | Preserved by the safety score batch; populated by price scoring when available. | +| `cctv_count_300m` | CCTV count within 300m. | +| `bell_count_300m` | Emergency bell count within 300m. | +| `light_count_300m` | Security light count within 300m. | +| `police_count_500m` | Police/security facility count within 500m. | +| `updated_at` | Last score-stat update timestamp. | + +The safety score batch upserts `safety_score` and the safety count fields while preserving existing `price_score`. New score-stat rows may have `price_score = null` until the price scoring flow fills it. diff --git a/docs/08_API_SPEC.md b/docs/08_API_SPEC.md index 5663d76..63a5712 100644 --- a/docs/08_API_SPEC.md +++ b/docs/08_API_SPEC.md @@ -713,3 +713,16 @@ Authorization: Bearer {token} GET /api/v1/sessions GET /api/v1/sessions/{sessionId}/messages ``` + +## F-4 Safety Analysis Contract + +`GET /api/v1/properties/{propertyId}/safety-summary?radius=500` returns stored values from `property_score_stat`. The endpoint must not call public safety APIs at request time. + +Backend AI `SAFETY_ANALYSIS` uses Spring Boot only: + +1. Read `selectedPropertyId` from the chat context. +2. Call `GET /api/v1/properties/{propertyId}/safety-summary?radius=500`. +3. Copy `safetyScore`, `cctvCount300m`, `bellCount300m`, `lightCount300m`, and `policeCount500m` into the safety analysis card metrics. +4. Generate a Korean answer that clearly shows the score and facility counts when present. + +If `selectedPropertyId` is missing, backend-ai returns a selection-required fallback instead of calling Spring. If Spring is unavailable, backend-ai returns a controlled fallback with `error = SPRING_API_UNAVAILABLE`. diff --git a/docs/09_BATCH_INGESTION.md b/docs/09_BATCH_INGESTION.md index c725b25..bff693d 100644 --- a/docs/09_BATCH_INGESTION.md +++ b/docs/09_BATCH_INGESTION.md @@ -202,3 +202,40 @@ for a metric, that metric contributes `0`. The upsert updates `safety_score` and safety facility counts while preserving existing `price_score`. New rows are inserted with `price_score = null` until a price scoring batch fills that value. + +## Phase 6 Safety Batch Runbook + +F-4 safety data is now a two-step stored-data flow: + +1. Safety facility ingestion reads configured public API sources and upserts normalized point data into `safety_facility`. +2. Property safety score recalculation reads only `safety_facility` and active geocoded `properties`, then upserts `property_score_stat`. + +Recommended monthly production order: + +```bash +SAFETY_INGESTION_SCHEDULER_ENABLED=true +SAFETY_SCORE_SCHEDULER_ENABLED=true +``` + +Default schedule in `Asia/Seoul`: + +| Step | Default cron | Purpose | +| --- | --- | --- | +| Safety facility ingestion | `0 0 3 1 * *` | Refresh CCTV, emergency bell, security light, and police/security facility point rows. | +| Property safety score recalculation | `0 30 3 1 * *` | Recalculate per-property safety score and facility counts after ingestion. | + +Required keys must be supplied through environment variables or platform secret settings, never committed: + +| Environment variable | Used by | +| --- | --- | +| `PUBLIC_DATA_SERVICE_KEY` | Public data sources such as emergency bell and security light when endpoint URLs require a service key. | +| `SAFEMAP_SERVICE_KEY` | SafetyMap police/security facility XML source. | + +Verification checklist: + +- `GET /api/v1/safety/facilities` returns stored point rows from `safety_facility`. +- `GET /api/v1/properties/{id}/safety-summary?radius=500` returns `safetyScore`, `priceScore`, and count fields from `property_score_stat`. +- Backend AI `SAFETY_ANALYSIS` calls Spring Boot `safety-summary` and surfaces the precomputed score/count fields in the analysis card and answer. +- User-facing APIs must not call public safety APIs directly. + +MVP scope is point-data safety facilities only. WMS-only safety layers remain excluded from this batch flow and should be handled as a separate future map-layer feature. diff --git a/docs/11_ROADMAP.md b/docs/11_ROADMAP.md index 070d95e..1750cc7 100644 --- a/docs/11_ROADMAP.md +++ b/docs/11_ROADMAP.md @@ -73,3 +73,16 @@ - 정교한 HUG/HF/SGI 판정 (등기부등본 데이터 기반) - 커뮤니티 (지역/건물 후기) - 개인화 추천 (찜·조회 이력 기반) + +## F-4 Implementation Status + +Safety facility ingestion and safety score calculation are implemented as stored-data batch flows: + +- Phase 4 stores CCTV, emergency bell, security light, and police/security facility point data in `safety_facility`. +- Phase 5 calculates per-property safety score/count fields and upserts `property_score_stat`. +- Phase 6 verifies backend-ai `SAFETY_ANALYSIS` consumes Spring Boot `safety-summary` and surfaces the precomputed score/count fields. + +Remaining outside MVP: + +- WMS-only safety map layers. +- Non-point safety datasets that cannot be normalized into `safety_facility`. From 2c4a47f188fe07087cc9fd6b3cf7791e74fa4491 Mon Sep 17 00:00:00 2001 From: HOKAGO-MEMORIES Date: Thu, 25 Jun 2026 02:29:08 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(ai):=20HUG=20=EC=9D=98=EB=8F=84=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-ai/app/clients/llm_client.py | 11 ++++++++++- .../tests/test_analysis_answer_generation.py | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/backend-ai/app/clients/llm_client.py b/backend-ai/app/clients/llm_client.py index 793d602..c66041d 100644 --- a/backend-ai/app/clients/llm_client.py +++ b/backend-ai/app/clients/llm_client.py @@ -15,6 +15,13 @@ HttpPost = Callable[..., httpx.Response] +RENDERABLE_WORKERS = { + "PROPERTY_SEARCH", + "LEGAL_CONSULT", + "PRICE_ANALYSIS", + "SAFETY_ANALYSIS", + "GENERAL_CHAT", +} SUPERVISOR_PROMPT = """\ 다음 사용자 메시지와 지금까지 실행된 워커 목록을 보고, 다음에 호출할 워커를 결정해줘. @@ -181,7 +188,9 @@ def generate_answer(self, state: AgentState) -> str: workers_called = state.get("workers_called", []) if not workers_called and state.get("intent"): intent = state["intent"] - workers_called = [str(getattr(intent, "value", intent))] + intent_worker = str(getattr(intent, "value", intent)) + if intent_worker in RENDERABLE_WORKERS: + workers_called = [intent_worker] if "GENERAL_CHAT" in workers_called: live = self._generate_live_general_chat_answer(state) diff --git a/backend-ai/tests/test_analysis_answer_generation.py b/backend-ai/tests/test_analysis_answer_generation.py index 1f5de66..bd62e07 100644 --- a/backend-ai/tests/test_analysis_answer_generation.py +++ b/backend-ai/tests/test_analysis_answer_generation.py @@ -249,3 +249,20 @@ def test_llm_client_safety_answer_preserves_zero_score_from_tool_result() -> Non assert "88점" not in answer assert "77점" not in answer assert "반경 300m" not in answer + + +def test_llm_client_does_not_promote_hug_calc_intent_to_renderable_worker() -> None: + answer = LLMClient(api_key="").generate_answer( + { + "user_id": "user-1", + "session_id": None, + "message": "HUG 보증 가능해?", + "context": {}, + "intent": Intent.HUG_CALC, + "analysis_cards": [], + "tool_results": {}, + } + ) + + assert "질문 의도를" in answer + assert "HUG 보증 가능" not in answer