refs #732: scope report titles to selected user

This commit is contained in:
devmrko
2026-08-10 18:02:49 +09:00
parent 84029ed633
commit cbd49b6b05
6 changed files with 225 additions and 6 deletions

View File

@@ -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: with _chat_db_connect() as connection:
rows = connection.execute( rows = connection.execute(
""" """
SELECT question, answer SELECT question, answer
FROM poc4_mcp_chat_turns FROM poc4_mcp_chat_turns
WHERE conversation_id = ? WHERE conversation_id = ?
AND (? = '' OR selected_user_id = ?)
ORDER BY turn_id DESC ORDER BY turn_id DESC
LIMIT ? LIMIT ?
""", """,
(conversation_id, CHAT_CONTEXT_TURNS), (
conversation_id,
current_user_id,
current_user_id,
CHAT_CONTEXT_TURNS,
),
).fetchall() ).fetchall()
messages: list[dict[str, str]] = [] messages: list[dict[str, str]] = []
for row in reversed(rows): for row in reversed(rows):
@@ -2997,6 +3008,7 @@ def _plan_presentation_title(
question: str, question: str,
source_step: Mapping[str, Any], source_step: Mapping[str, Any],
model_profile_key: str, model_profile_key: str,
selected_user_id: str = "",
) -> str: ) -> str:
"""Generate a concise business title without coupling to a specific report.""" """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 " "Create one concise Korean enterprise report heading. Preserve the "
"business subject, team/user scope, and identifiers. Remove output-format " "business subject, team/user scope, and identifiers. Remove output-format "
"and action phrases such as HTML, report, dashboard, 보여줘, 만들어줘. " "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 " "Use a noun phrase, not a sentence. The template separately appends a fixed "
"subtitle, so do not include '최신 선사 실적'. Return only schema JSON." "subtitle, so do not include '최신 선사 실적'. Return only schema JSON."
), ),
user_prompt=json.dumps( user_prompt=json.dumps(
{ {
"question": question, "question": question,
"current_selected_user_id": str(selected_user_id or "").strip(),
"source_tool": str(source_step.get("tool_name") or ""), "source_tool": str(source_step.get("tool_name") or ""),
"source_rows": len( "source_rows": len(
_mcp_structured_rows(source_step.get("mcp_result", {})) _mcp_structured_rows(source_step.get("mcp_result", {}))
@@ -3048,6 +3065,7 @@ def _presentation_payload(
steps: list[Mapping[str, Any]], steps: list[Mapping[str, Any]],
*, *,
title: str = "", title: str = "",
selected_user_id: str = "",
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Build a renderer payload only from the preceding authorized tool result.""" """Build a renderer payload only from the preceding authorized tool result."""
@@ -3067,6 +3085,9 @@ def _presentation_payload(
if isinstance(row, Mapping) if isinstance(row, Mapping)
] ]
requester = re.search(r"\bE\d{4,}\b", str(question or ""), flags=re.IGNORECASE) 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") source_tool = str(source.get("tool_name") or "MCP data query")
return { return {
"report": { "report": {
@@ -3074,7 +3095,7 @@ def _presentation_payload(
"category": "MCP Business Intelligence", "category": "MCP Business Intelligence",
"title": _clean_presentation_title(title), "title": _clean_presentation_title(title),
"question": str(question or ""), "question": str(question or ""),
"requestedBy": requester.group(0).upper() if requester else "-", "requestedBy": requested_by or "-",
"generatedAt": datetime.now(timezone.utc).isoformat(), "generatedAt": datetime.now(timezone.utc).isoformat(),
"answer": ( "answer": (
f"{source_tool}에서 권한 범위 내 결과 {len(rows)}건을 받아 " f"{source_tool}에서 권한 범위 내 결과 {len(rows)}건을 받아 "
@@ -3100,6 +3121,7 @@ def _build_mcp_arguments(
preferred_tool: str, preferred_tool: str,
steps: list[Mapping[str, Any]], steps: list[Mapping[str, Any]],
presentation_title: str = "", presentation_title: str = "",
selected_user_id: str = "",
) -> dict[str, Any]: ) -> dict[str, Any]:
if not _is_presentation_route(route): if not _is_presentation_route(route):
return build_mcp_tool_arguments( return build_mcp_tool_arguments(
@@ -3131,6 +3153,7 @@ def _build_mcp_arguments(
tool_query, tool_query,
steps, steps,
title=presentation_title, title=presentation_title,
selected_user_id=selected_user_id,
), ),
ensure_ascii=False, ensure_ascii=False,
) )
@@ -3892,6 +3915,7 @@ def run_mcp_agent_loop(
bearer_token: str, bearer_token: str,
limit: int, limit: int,
model_profile_key: str, model_profile_key: str,
selected_user_id: str = "",
initial_route_key: str = "", initial_route_key: str = "",
progress_callback: Any = None, progress_callback: Any = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -4112,6 +4136,7 @@ def run_mcp_agent_loop(
question=question, question=question,
source_step=source_step, source_step=source_step,
model_profile_key=model_profile_key, model_profile_key=model_profile_key,
selected_user_id=selected_user_id,
) )
arguments = _build_mcp_arguments( arguments = _build_mcp_arguments(
route=route, route=route,
@@ -4120,6 +4145,7 @@ def run_mcp_agent_loop(
preferred_tool=server.default_tool, preferred_tool=server.default_tool,
steps=steps, steps=steps,
presentation_title=presentation_title, presentation_title=presentation_title,
selected_user_id=selected_user_id,
) )
raw_result = call_tool( raw_result = call_tool(
base_url=server.endpoint_url, base_url=server.endpoint_url,
@@ -4324,9 +4350,11 @@ def resolve_standalone_question(
question: str, question: str,
messages: list[Mapping[str, Any]], messages: list[Mapping[str, Any]],
model_profile_key: str, model_profile_key: str,
selected_user_id: str = "",
) -> str: ) -> str:
context = _conversation_context(messages) 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 return question
try: try:
profile = resolve_model_profile(model_profile_key) profile = resolve_model_profile(model_profile_key)
@@ -4339,10 +4367,19 @@ def resolve_standalone_question(
system_prompt=( system_prompt=(
"Rewrite the user's latest Korean question into one standalone " "Rewrite the user's latest Korean question into one standalone "
"MCP tool query. Use the conversation only to resolve references. " "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." "Do not answer the question."
), ),
user_prompt=json.dumps( user_prompt=json.dumps(
{"conversation": context, "latest_question": question}, {
"current_selected_user_id": current_user_id,
"conversation": context,
"latest_question": question,
},
ensure_ascii=False, ensure_ascii=False,
), ),
response_schema={ response_schema={
@@ -5454,7 +5491,15 @@ def _process_submitted_question(
with processing_status: with processing_status:
refresh_progress(5, "대화 문맥을 불러오고 있습니다.") refresh_progress(5, "대화 문맥을 불러오고 있습니다.")
step_started = perf_counter() 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 step_elapsed = perf_counter() - step_started
refresh_progress( refresh_progress(
15, 15,
@@ -5469,6 +5514,7 @@ def _process_submitted_question(
question=normalized_question, question=normalized_question,
messages=conversation, messages=conversation,
model_profile_key=active_model_profile, model_profile_key=active_model_profile,
selected_user_id=current_selected_user_id,
) )
step_elapsed = perf_counter() - step_started step_elapsed = perf_counter() - step_started
refresh_progress( refresh_progress(
@@ -5591,6 +5637,7 @@ def _process_submitted_question(
bearer_token=bearer_token, bearer_token=bearer_token,
limit=int(limit), limit=int(limit),
model_profile_key=reasoning_model_profile, model_profile_key=reasoning_model_profile,
selected_user_id=current_selected_user_id,
initial_route_key=_route_key( initial_route_key=_route_key(
selected_route.server_id, selected_route.server_id,
selected_route.tool.name, selected_route.tool.name,

View File

@@ -6,6 +6,7 @@ import json
from pathlib import Path from pathlib import Path
import re import re
import tempfile import tempfile
from types import SimpleNamespace
from typing import Any, Mapping from typing import Any, Mapping
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
@@ -237,6 +238,132 @@ class DemoScenarioConfigTest(unittest.TestCase):
source, 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: def test_hmm_report_data_query_drops_html_format_request(self) -> None:
source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8") source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8")
tree = ast.parse(source) tree = ast.parse(source)

View File

@@ -14,6 +14,7 @@ DB를 다시 조회하거나 자연어를 해석하지 않는다.
- 선사 실적 조회 MCP가 VPD를 적용한 데이터 접근 경계이고, 리포트 MCP는 표현 경계다. - 선사 실적 조회 MCP가 VPD를 적용한 데이터 접근 경계이고, 리포트 MCP는 표현 경계다.
- 승인된 HTML 템플릿은 DB CLOB으로 버전 관리하고, 생성 HTML은 MCP 응답의 `html` 속성으로만 반환한다. - 승인된 HTML 템플릿은 DB CLOB으로 버전 관리하고, 생성 HTML은 MCP 응답의 `html` 속성으로만 반환한다.
- 제목은 질문 전체 문장을 복사하지 않는다. 포털의 제목 생성 지침이 요청 대상과 업무 범위만 남긴 짧은 보고서 제목을 만들고 renderer에 전달한다. - 제목은 질문 전체 문장을 복사하지 않는다. 포털의 제목 생성 지침이 요청 대상과 업무 범위만 남긴 짧은 보고서 제목을 만들고 renderer에 전달한다.
- 같은 대화에서 데모 사용자를 전환해도 현재 선택 사용자 ID가 질문의 `내`와 제목 범위를 결정한다. 다른 사용자의 이전 대화는 현재 사용자의 문맥으로 사용하지 않는다.
- HTML artifact가 반환되면 일반 답변은 HTML 표나 코드를 반복하지 않고 생성 완료와 조회 건수만 안내한다. 실제 표현은 `생성된 리포트` 영역 하나에서 담당한다. - HTML artifact가 반환되면 일반 답변은 HTML 표나 코드를 반복하지 않고 생성 완료와 조회 건수만 안내한다. 실제 표현은 `생성된 리포트` 영역 하나에서 담당한다.
- 포털은 사용자 질문을 그대로 두 Tool에 재사용하지 않는다. 첫 조회에는 데이터 조건만 남긴 질문을 전달하고, `HTML로 보여줘` 같은 표현 요청은 renderer 선택과 제목 생성에만 사용한다. - 포털은 사용자 질문을 그대로 두 Tool에 재사용하지 않는다. 첫 조회에는 데이터 조건만 남긴 질문을 전달하고, `HTML로 보여줘` 같은 표현 요청은 renderer 선택과 제목 생성에만 사용한다.
@@ -42,3 +43,6 @@ Oracle DB의 custom Agent Tool과 템플릿 CLOB은 적용됐다. 포털은 리
8건, HTML 18,670자를 연속 호출해 확인했다. 조회 응답은 `response.result` 안의 JSON 배열 문자열로 8건, HTML 18,670자를 연속 호출해 확인했다. 조회 응답은 `response.result` 안의 JSON 배열 문자열로
반환되므로 포털이 이를 범용적으로 구조화한다. HTML 템플릿에는 업무 샘플 행을 저장하지 않으며, 반환되므로 포털이 이를 범용적으로 구조화한다. HTML 템플릿에는 업무 샘플 행을 저장하지 않으며,
호출 시 전달된 `report``rows`만 표시한다. 호출 시 전달된 `report``rows`만 표시한다.
2026-08-10 사용자 전환 회귀 검증에서는 E1001 제목이 팀장·팀원 범위를 유지하고, 같은 대화에서
E1002로 전환한 `내 담당 선사` 제목은 E1002 범위로 생성되며 E1001 식별자가 섞이지 않음을 확인했다.

View File

@@ -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가 최종 표현물이다. renderer 응답에 유효한 `html` 또는 `rendered_html`이 있으면 HTML artifact가 최종 표현물이다.

View File

@@ -40,6 +40,9 @@
6. 리포트 머리글이 질문 전체 문장이 아니라 출력 형식 문구를 제거한 짧은 업무 제목인지 확인한다. 6. 리포트 머리글이 질문 전체 문장이 아니라 출력 형식 문구를 제거한 짧은 업무 제목인지 확인한다.
7. `reportJson.rows`의 각 행이 `employeeCode`, `carrierCode`, KPI처럼 업무 필드를 가지며, 7. `reportJson.rows`의 각 행이 `employeeCode`, `carrierCode`, KPI처럼 업무 필드를 가지며,
`htmlRow``<tr>` 문자열을 포함하지 않는지 확인한다. `htmlRow``<tr>` 문자열을 포함하지 않는지 확인한다.
8. 같은 대화에서 사용자를 E1001에서 E1002로 전환한 뒤 `내 담당 선사 ... 리포트로 보여줘`
요청한다. E1002의 독립 질문·제목·`report.requestedBy`에 E1001이 없어야 하며, E1002 권한 범위의
행만 표시되어야 한다.
대표 E1001 팀장 질의의 운영 회귀 기준은 현재 8건이다. 이 숫자는 검증 기준일 뿐 코드나 Tool 대표 E1001 팀장 질의의 운영 회귀 기준은 현재 8건이다. 이 숫자는 검증 기준일 뿐 코드나 Tool
인수에 고정하지 않는다. 첫 단계 원본에 행이 있는데 `reportJson.rows`가 0건이면 순차 실행 성공이 인수에 고정하지 않는다. 첫 단계 원본에 행이 있는데 `reportJson.rows`가 0건이면 순차 실행 성공이

View File

@@ -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>`만 포함함 ## `reportJson.rows`가 `htmlRow`와 `<tr>`만 포함함
**원인**: 포털이 `HTML로 보여줘`가 포함된 원문을 첫 Select AI 데이터 조회에 그대로 전달해, **원인**: 포털이 `HTML로 보여줘`가 포함된 원문을 첫 Select AI 데이터 조회에 그대로 전달해,