refs #732: scope report titles to selected user
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 식별자가 섞이지 않음을 확인했다.
|
||||
|
||||
@@ -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가 최종 표현물이다.
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
6. 리포트 머리글이 질문 전체 문장이 아니라 출력 형식 문구를 제거한 짧은 업무 제목인지 확인한다.
|
||||
7. `reportJson.rows`의 각 행이 `employeeCode`, `carrierCode`, KPI처럼 업무 필드를 가지며,
|
||||
`htmlRow`나 `<tr>` 문자열을 포함하지 않는지 확인한다.
|
||||
8. 같은 대화에서 사용자를 E1001에서 E1002로 전환한 뒤 `내 담당 선사 ... 리포트로 보여줘`를
|
||||
요청한다. E1002의 독립 질문·제목·`report.requestedBy`에 E1001이 없어야 하며, E1002 권한 범위의
|
||||
행만 표시되어야 한다.
|
||||
|
||||
대표 E1001 팀장 질의의 운영 회귀 기준은 현재 8건이다. 이 숫자는 검증 기준일 뿐 코드나 Tool
|
||||
인수에 고정하지 않는다. 첫 단계 원본에 행이 있는데 `reportJson.rows`가 0건이면 순차 실행 성공이
|
||||
|
||||
@@ -60,6 +60,27 @@
|
||||
|
||||
**재발 방지**: 제목과 원문 질문이 역할상 분리되고 제목 길이 제한이 적용되는지 검증한다.
|
||||
|
||||
## 사용자를 바꿨는데 이전 사용자의 이름·팀이 제목에 남음
|
||||
|
||||
**증상**: E1001로 리포트를 만든 뒤 같은 대화에서 E1002를 선택하고 `내 담당 선사`를 요청했는데,
|
||||
데이터는 E1002의 2건이면서 제목은 `E1001 팀장 ...`으로 표시된다.
|
||||
|
||||
**원인**: 질문 독립화 단계가 사용자 구분 없이 이전 대화 turn을 불러와 `내`를 이전 사용자인
|
||||
E1001로 치환했다. VPD는 현재 Bearer token으로 정상 적용되므로 데이터와 제목 범위가 어긋난다.
|
||||
|
||||
**확인**:
|
||||
|
||||
1. 저장된 turn의 `selected_user_id`, `question`, `standalone_question`을 비교한다.
|
||||
2. E1002 turn의 `standalone_question` 또는 renderer `report.title`에 E1001이 남아 있는지 확인한다.
|
||||
3. 선행 조회 행은 E1002 담당 선사만 반환되는지 별도로 확인한다.
|
||||
|
||||
**해결**: 대화 문맥을 현재 `selected_user_id`로 필터링하고, 질문 독립화와 제목 생성에 현재 사용자
|
||||
ID를 명시적으로 전달한다. 1인칭은 현재 사용자만 가리키며 이전 사용자의 관리자·팀 범위를 재사용하지
|
||||
않도록 공통 지침을 적용한다. `report.requestedBy`도 현재 선택 사용자 ID를 사용한다.
|
||||
|
||||
**재발 방지**: 한 대화에서 E1001→E1002로 연속 전환하는 회귀 테스트를 유지한다. 제목과 행 수를
|
||||
사용자별로 고정하지 말고 E1002 결과에 E1001 식별자가 포함되지 않는지를 검사한다.
|
||||
|
||||
## `reportJson.rows`가 `htmlRow`와 `<tr>`만 포함함
|
||||
|
||||
**원인**: 포털이 `HTML로 보여줘`가 포함된 원문을 첫 Select AI 데이터 조회에 그대로 전달해,
|
||||
|
||||
Reference in New Issue
Block a user