diff --git a/ai-web-agent-console/app.py b/ai-web-agent-console/app.py index 41fd3a1..b006109 100644 --- a/ai-web-agent-console/app.py +++ b/ai-web-agent-console/app.py @@ -443,17 +443,28 @@ def _is_failed_synthesis_answer(value: object) -> bool: ) -def load_chat_context(conversation_id: str) -> list[dict[str, str]]: +def load_chat_context( + conversation_id: str, + *, + selected_user_id: str = "", +) -> list[dict[str, str]]: + current_user_id = str(selected_user_id or "").strip() with _chat_db_connect() as connection: rows = connection.execute( """ SELECT question, answer FROM poc4_mcp_chat_turns WHERE conversation_id = ? + AND (? = '' OR selected_user_id = ?) ORDER BY turn_id DESC LIMIT ? """, - (conversation_id, CHAT_CONTEXT_TURNS), + ( + conversation_id, + current_user_id, + current_user_id, + CHAT_CONTEXT_TURNS, + ), ).fetchall() messages: list[dict[str, str]] = [] for row in reversed(rows): @@ -2997,6 +3008,7 @@ def _plan_presentation_title( question: str, source_step: Mapping[str, Any], model_profile_key: str, + selected_user_id: str = "", ) -> str: """Generate a concise business title without coupling to a specific report.""" @@ -3013,12 +3025,17 @@ def _plan_presentation_title( "Create one concise Korean enterprise report heading. Preserve the " "business subject, team/user scope, and identifiers. Remove output-format " "and action phrases such as HTML, report, dashboard, 보여줘, 만들어줘. " + "The current selected user ID is authoritative. First-person expressions " + "such as 내, 나, and 우리 refer to that current user, never to a user from " + "earlier conversation turns. Do not change the current user into a manager " + "or another employee unless the latest question explicitly names that scope. " "Use a noun phrase, not a sentence. The template separately appends a fixed " "subtitle, so do not include '최신 선사 실적'. Return only schema JSON." ), user_prompt=json.dumps( { "question": question, + "current_selected_user_id": str(selected_user_id or "").strip(), "source_tool": str(source_step.get("tool_name") or ""), "source_rows": len( _mcp_structured_rows(source_step.get("mcp_result", {})) @@ -3048,6 +3065,7 @@ def _presentation_payload( steps: list[Mapping[str, Any]], *, title: str = "", + selected_user_id: str = "", ) -> dict[str, Any]: """Build a renderer payload only from the preceding authorized tool result.""" @@ -3067,6 +3085,9 @@ def _presentation_payload( if isinstance(row, Mapping) ] requester = re.search(r"\bE\d{4,}\b", str(question or ""), flags=re.IGNORECASE) + requested_by = str(selected_user_id or "").strip().upper() + if not requested_by and requester: + requested_by = requester.group(0).upper() source_tool = str(source.get("tool_name") or "MCP data query") return { "report": { @@ -3074,7 +3095,7 @@ def _presentation_payload( "category": "MCP Business Intelligence", "title": _clean_presentation_title(title), "question": str(question or ""), - "requestedBy": requester.group(0).upper() if requester else "-", + "requestedBy": requested_by or "-", "generatedAt": datetime.now(timezone.utc).isoformat(), "answer": ( f"{source_tool}에서 권한 범위 내 결과 {len(rows)}건을 받아 " @@ -3100,6 +3121,7 @@ def _build_mcp_arguments( preferred_tool: str, steps: list[Mapping[str, Any]], presentation_title: str = "", + selected_user_id: str = "", ) -> dict[str, Any]: if not _is_presentation_route(route): return build_mcp_tool_arguments( @@ -3131,6 +3153,7 @@ def _build_mcp_arguments( tool_query, steps, title=presentation_title, + selected_user_id=selected_user_id, ), ensure_ascii=False, ) @@ -3892,6 +3915,7 @@ def run_mcp_agent_loop( bearer_token: str, limit: int, model_profile_key: str, + selected_user_id: str = "", initial_route_key: str = "", progress_callback: Any = None, ) -> dict[str, Any]: @@ -4112,6 +4136,7 @@ def run_mcp_agent_loop( question=question, source_step=source_step, model_profile_key=model_profile_key, + selected_user_id=selected_user_id, ) arguments = _build_mcp_arguments( route=route, @@ -4120,6 +4145,7 @@ def run_mcp_agent_loop( preferred_tool=server.default_tool, steps=steps, presentation_title=presentation_title, + selected_user_id=selected_user_id, ) raw_result = call_tool( base_url=server.endpoint_url, @@ -4324,9 +4350,11 @@ def resolve_standalone_question( question: str, messages: list[Mapping[str, Any]], model_profile_key: str, + selected_user_id: str = "", ) -> str: context = _conversation_context(messages) - if not context: + current_user_id = str(selected_user_id or "").strip() + if not context and not current_user_id: return question try: profile = resolve_model_profile(model_profile_key) @@ -4339,10 +4367,19 @@ def resolve_standalone_question( system_prompt=( "Rewrite the user's latest Korean question into one standalone " "MCP tool query. Use the conversation only to resolve references. " + "The current selected user ID is authoritative. First-person expressions " + "such as 내, 나, and 우리 refer only to the current selected user. Never " + "reuse another user's identity, manager scope, or team scope from earlier " + "conversation turns. If the latest question is self-contained, preserve its " + "meaning and scope. " "Do not answer the question." ), user_prompt=json.dumps( - {"conversation": context, "latest_question": question}, + { + "current_selected_user_id": current_user_id, + "conversation": context, + "latest_question": question, + }, ensure_ascii=False, ), response_schema={ @@ -5454,7 +5491,15 @@ def _process_submitted_question( with processing_status: refresh_progress(5, "대화 문맥을 불러오고 있습니다.") step_started = perf_counter() - conversation = load_chat_context(conversation_id) + current_selected_user_id = ( + selected_token_preset.user_id + if selected_token_preset is not None + else "" + ) + conversation = load_chat_context( + conversation_id, + selected_user_id=current_selected_user_id, + ) step_elapsed = perf_counter() - step_started refresh_progress( 15, @@ -5469,6 +5514,7 @@ def _process_submitted_question( question=normalized_question, messages=conversation, model_profile_key=active_model_profile, + selected_user_id=current_selected_user_id, ) step_elapsed = perf_counter() - step_started refresh_progress( @@ -5591,6 +5637,7 @@ def _process_submitted_question( bearer_token=bearer_token, limit=int(limit), model_profile_key=reasoning_model_profile, + selected_user_id=current_selected_user_id, initial_route_key=_route_key( selected_route.server_id, selected_route.tool.name, diff --git a/ai-web-agent-console/tests/test_scenarios.py b/ai-web-agent-console/tests/test_scenarios.py index 988c968..8ac5d02 100644 --- a/ai-web-agent-console/tests/test_scenarios.py +++ b/ai-web-agent-console/tests/test_scenarios.py @@ -6,6 +6,7 @@ import json from pathlib import Path import re import tempfile +from types import SimpleNamespace from typing import Any, Mapping import unittest from unittest.mock import patch @@ -237,6 +238,132 @@ class DemoScenarioConfigTest(unittest.TestCase): source, ) + def test_chat_context_is_scoped_to_current_selected_user(self) -> None: + source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8") + tree = ast.parse(source) + helper = next( + node + for node in tree.body + if isinstance(node, ast.FunctionDef) + and node.name == "load_chat_context" + ) + captured: dict[str, Any] = {} + + class FakeConnection: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def execute(self, sql, params): + captured["sql"] = sql + captured["params"] = params + return self + + def fetchall(self): + return [{"question": "내 담당 선사", "answer": "2건"}] + + namespace: dict[str, Any] = { + "CHAT_CONTEXT_TURNS": 8, + "MAX_CONVERSATION_MESSAGES": 16, + "_chat_db_connect": FakeConnection, + "_is_failed_synthesis_answer": lambda _value: False, + } + exec(compile(ast.Module(body=[helper], type_ignores=[]), "app.py", "exec"), namespace) + + messages = namespace["load_chat_context"]( + "conversation-1", + selected_user_id="E1002", + ) + + self.assertIn("selected_user_id = ?", captured["sql"]) + self.assertEqual( + captured["params"], + ("conversation-1", "E1002", "E1002", 8), + ) + self.assertEqual(messages[0]["content"], "내 담당 선사") + + def test_standalone_question_uses_current_user_without_prior_context(self) -> None: + source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8") + tree = ast.parse(source) + helpers = [ + node + for node in tree.body + if isinstance(node, ast.FunctionDef) + and node.name in {"_conversation_context", "resolve_standalone_question"} + ] + captured: dict[str, Any] = {} + + class FakeClient: + def complete(self, **kwargs): + captured.update(kwargs) + return json.dumps( + { + "standalone_question": ( + "E1002 사용자의 담당 선사 최신 실적을 조회해줘" + ) + }, + ensure_ascii=False, + ) + + namespace: dict[str, Any] = { + "Any": Any, + "Mapping": Mapping, + "MAX_CONVERSATION_MESSAGES": 16, + "json": json, + "resolve_model_profile": lambda _key: SimpleNamespace( + model_id="model", + answer_model_region="region", + answer_model_endpoint="endpoint", + ), + "build_oci_genai_completion_client": lambda *_args: FakeClient(), + "temperature_for_model_profile": lambda _profile: 0.0, + } + exec(compile(ast.Module(body=helpers, type_ignores=[]), "app.py", "exec"), namespace) + + rewritten = namespace["resolve_standalone_question"]( + question="내 담당 선사 최신 실적을 리포트로 보여줘", + messages=[], + model_profile_key="test", + selected_user_id="E1002", + ) + prompt_payload = json.loads(captured["user_prompt"]) + + self.assertTrue(rewritten.startswith("E1002")) + self.assertEqual(prompt_payload["current_selected_user_id"], "E1002") + self.assertIn("authoritative", captured["system_prompt"]) + + def test_report_payload_requester_prefers_current_selected_user(self) -> None: + source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8") + tree = ast.parse(source) + helper = next( + node + for node in tree.body + if isinstance(node, ast.FunctionDef) + and node.name == "_presentation_payload" + ) + namespace: dict[str, Any] = { + "Any": Any, + "Mapping": Mapping, + "datetime": __import__("datetime").datetime, + "timezone": __import__("datetime").timezone, + "re": re, + "_mcp_structured_rows": lambda _value: [], + "_normalize_presentation_value": lambda value: value, + "_clean_presentation_title": lambda value: str(value), + } + exec(compile(ast.Module(body=[helper], type_ignores=[]), "app.py", "exec"), namespace) + + payload = namespace["_presentation_payload"]( + "E1001 팀장 문맥이 남은 질문", + [], + title="담당 선사 실적", + selected_user_id="E1002", + ) + + self.assertEqual(payload["report"]["requestedBy"], "E1002") + def test_hmm_report_data_query_drops_html_format_request(self) -> None: source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8") tree = ast.parse(source) diff --git a/docs/design/hmm-html-report-mcp/README.md b/docs/design/hmm-html-report-mcp/README.md index c208749..a777d6d 100644 --- a/docs/design/hmm-html-report-mcp/README.md +++ b/docs/design/hmm-html-report-mcp/README.md @@ -14,6 +14,7 @@ DB를 다시 조회하거나 자연어를 해석하지 않는다. - 선사 실적 조회 MCP가 VPD를 적용한 데이터 접근 경계이고, 리포트 MCP는 표현 경계다. - 승인된 HTML 템플릿은 DB CLOB으로 버전 관리하고, 생성 HTML은 MCP 응답의 `html` 속성으로만 반환한다. - 제목은 질문 전체 문장을 복사하지 않는다. 포털의 제목 생성 지침이 요청 대상과 업무 범위만 남긴 짧은 보고서 제목을 만들고 renderer에 전달한다. +- 같은 대화에서 데모 사용자를 전환해도 현재 선택 사용자 ID가 질문의 `내`와 제목 범위를 결정한다. 다른 사용자의 이전 대화는 현재 사용자의 문맥으로 사용하지 않는다. - HTML artifact가 반환되면 일반 답변은 HTML 표나 코드를 반복하지 않고 생성 완료와 조회 건수만 안내한다. 실제 표현은 `생성된 리포트` 영역 하나에서 담당한다. - 포털은 사용자 질문을 그대로 두 Tool에 재사용하지 않는다. 첫 조회에는 데이터 조건만 남긴 질문을 전달하고, `HTML로 보여줘` 같은 표현 요청은 renderer 선택과 제목 생성에만 사용한다. @@ -42,3 +43,6 @@ Oracle DB의 custom Agent Tool과 템플릿 CLOB은 적용됐다. 포털은 리 8건, HTML 18,670자를 연속 호출해 확인했다. 조회 응답은 `response.result` 안의 JSON 배열 문자열로 반환되므로 포털이 이를 범용적으로 구조화한다. HTML 템플릿에는 업무 샘플 행을 저장하지 않으며, 호출 시 전달된 `report`와 `rows`만 표시한다. + +2026-08-10 사용자 전환 회귀 검증에서는 E1001 제목이 팀장·팀원 범위를 유지하고, 같은 대화에서 +E1002로 전환한 `내 담당 선사` 제목은 E1002 범위로 생성되며 E1001 식별자가 섞이지 않음을 확인했다. diff --git a/docs/design/hmm-html-report-mcp/architecture.md b/docs/design/hmm-html-report-mcp/architecture.md index 93196d8..46a339c 100644 --- a/docs/design/hmm-html-report-mcp/architecture.md +++ b/docs/design/hmm-html-report-mcp/architecture.md @@ -65,6 +65,23 @@ Tool 이름, 사용자 코드나 예상 행 수를 조건으로 사용하지 않 제목 생성이 실패하면 전체 질문을 제목으로 사용하지 않고 짧은 일반 업무 제목으로 안전하게 대체한다. 이 규칙은 특정 사용자 코드나 조회 행 수를 조건으로 사용하지 않는다. +### 현재 선택 사용자와 대화 문맥 + +포털의 데모 사용자 선택값은 Bearer token 선택뿐 아니라 질문 해석과 제목 범위의 기준이다. +`내`, `나`, `우리` 같은 1인칭 표현은 현재 선택 사용자 ID를 기준으로 독립 질문으로 바꾼다. +대화 이력은 같은 `selected_user_id`로 저장된 turn만 불러오며, 제목 생성 모델에도 현재 선택 사용자 +ID를 별도 입력으로 전달한다. 이전 사용자의 질문에 명시된 팀장·팀 범위가 현재 사용자의 제목으로 +전파되어서는 안 된다. + +```text +현재 사용자 E1001 + "E1001 팀장의 팀원별 ..." → E1001 팀 범위 제목 → VPD 결과 8건 +현재 사용자 E1002 + "내 담당 선사 ..." → E1002 개인 범위 제목 → VPD 결과 2건 +``` + +제목 문자열이나 예상 행 수를 사용자별로 하드코딩하지 않는다. 사용자 ID, 독립 질문, 실제 선행 조회 +결과를 모델 입력으로 제공하고 공통 제목 지침으로 생성한다. `report.requestedBy`는 질문에서 추측하지 +않고 현재 선택 사용자 ID를 사용한다. + ## 포털 표시 계약 renderer 응답에 유효한 `html` 또는 `rendered_html`이 있으면 HTML artifact가 최종 표현물이다. diff --git a/docs/design/hmm-html-report-mcp/cookbook.md b/docs/design/hmm-html-report-mcp/cookbook.md index c507fe6..f7c1bbf 100644 --- a/docs/design/hmm-html-report-mcp/cookbook.md +++ b/docs/design/hmm-html-report-mcp/cookbook.md @@ -40,6 +40,9 @@ 6. 리포트 머리글이 질문 전체 문장이 아니라 출력 형식 문구를 제거한 짧은 업무 제목인지 확인한다. 7. `reportJson.rows`의 각 행이 `employeeCode`, `carrierCode`, KPI처럼 업무 필드를 가지며, `htmlRow`나 `