refs #732: polish HMM report title and response
This commit is contained in:
@@ -2886,7 +2886,71 @@ def _normalize_presentation_value(value: Any) -> Any:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _presentation_payload(question: str, steps: list[Mapping[str, Any]]) -> dict[str, Any]:
|
def _clean_presentation_title(value: Any) -> str:
|
||||||
|
text = html.unescape(re.sub(r"<[^>]+>", " ", str(value or "")))
|
||||||
|
text = " ".join(text.split()).strip(" \t\r\n\"'`-–—:;,.!?·")
|
||||||
|
if len(text) > 48:
|
||||||
|
text = text[:47].rstrip() + "…"
|
||||||
|
return text or "업무 현황"
|
||||||
|
|
||||||
|
|
||||||
|
def _plan_presentation_title(
|
||||||
|
*,
|
||||||
|
question: str,
|
||||||
|
source_step: Mapping[str, Any],
|
||||||
|
model_profile_key: str,
|
||||||
|
) -> str:
|
||||||
|
"""Generate a concise business title without coupling to a specific report."""
|
||||||
|
|
||||||
|
fallback = "업무 현황"
|
||||||
|
try:
|
||||||
|
profile = resolve_model_profile(model_profile_key)
|
||||||
|
client = build_oci_genai_completion_client(
|
||||||
|
profile.model_id,
|
||||||
|
profile.answer_model_region,
|
||||||
|
profile.answer_model_endpoint,
|
||||||
|
)
|
||||||
|
text = client.complete(
|
||||||
|
system_prompt=(
|
||||||
|
"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, 보여줘, 만들어줘. "
|
||||||
|
"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,
|
||||||
|
"source_tool": str(source_step.get("tool_name") or ""),
|
||||||
|
"source_rows": len(
|
||||||
|
_mcp_structured_rows(source_step.get("mcp_result", {}))
|
||||||
|
),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
response_schema={
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": False,
|
||||||
|
"required": ["title"],
|
||||||
|
"properties": {"title": {"type": "string"}},
|
||||||
|
},
|
||||||
|
max_tokens=120,
|
||||||
|
temperature=temperature_for_model_profile(profile),
|
||||||
|
)
|
||||||
|
parsed = json.loads(text)
|
||||||
|
if isinstance(parsed, Mapping):
|
||||||
|
return _clean_presentation_title(parsed.get("title"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _presentation_payload(
|
||||||
|
question: str,
|
||||||
|
steps: list[Mapping[str, Any]],
|
||||||
|
*,
|
||||||
|
title: str = "",
|
||||||
|
) -> 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."""
|
||||||
|
|
||||||
source = next(
|
source = next(
|
||||||
@@ -2910,7 +2974,7 @@ def _presentation_payload(question: str, steps: list[Mapping[str, Any]]) -> dict
|
|||||||
"report": {
|
"report": {
|
||||||
"id": "MCP-REPORT",
|
"id": "MCP-REPORT",
|
||||||
"category": "MCP Business Intelligence",
|
"category": "MCP Business Intelligence",
|
||||||
"title": str(question or "MCP 결과 리포트"),
|
"title": _clean_presentation_title(title),
|
||||||
"question": str(question or ""),
|
"question": str(question or ""),
|
||||||
"requestedBy": requester.group(0).upper() if requester else "-",
|
"requestedBy": requester.group(0).upper() if requester else "-",
|
||||||
"generatedAt": datetime.now(timezone.utc).isoformat(),
|
"generatedAt": datetime.now(timezone.utc).isoformat(),
|
||||||
@@ -2937,6 +3001,7 @@ def _build_mcp_arguments(
|
|||||||
limit: int,
|
limit: int,
|
||||||
preferred_tool: str,
|
preferred_tool: str,
|
||||||
steps: list[Mapping[str, Any]],
|
steps: list[Mapping[str, Any]],
|
||||||
|
presentation_title: 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(
|
||||||
@@ -2964,12 +3029,63 @@ def _build_mcp_arguments(
|
|||||||
raise McpToolRouterError("렌더링 MCP tool이 report payload 입력을 선언하지 않았습니다.")
|
raise McpToolRouterError("렌더링 MCP tool이 report payload 입력을 선언하지 않았습니다.")
|
||||||
return {
|
return {
|
||||||
input_name: json.dumps(
|
input_name: json.dumps(
|
||||||
_presentation_payload(tool_query, steps),
|
_presentation_payload(
|
||||||
|
tool_query,
|
||||||
|
steps,
|
||||||
|
title=presentation_title,
|
||||||
|
),
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _presentation_artifact_summary(
|
||||||
|
mcp_result: Any,
|
||||||
|
agent_steps: list[Mapping[str, Any]] | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not _mcp_rendered_html(mcp_result):
|
||||||
|
return {}
|
||||||
|
title = "업무 현황"
|
||||||
|
row_count = 0
|
||||||
|
for step in reversed(agent_steps or []):
|
||||||
|
if not isinstance(step, Mapping):
|
||||||
|
continue
|
||||||
|
if not bool(step.get("consumes_previous_result")):
|
||||||
|
row_count = len(_mcp_structured_rows(step.get("mcp_result", {})))
|
||||||
|
continue
|
||||||
|
arguments = step.get("arguments")
|
||||||
|
if not isinstance(arguments, Mapping):
|
||||||
|
continue
|
||||||
|
for raw_payload in arguments.values():
|
||||||
|
if not isinstance(raw_payload, str):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw_payload)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
report = parsed.get("report") if isinstance(parsed, Mapping) else None
|
||||||
|
if isinstance(report, Mapping):
|
||||||
|
title = _clean_presentation_title(report.get("title"))
|
||||||
|
rows = parsed.get("rows")
|
||||||
|
if isinstance(rows, list):
|
||||||
|
row_count = len(rows)
|
||||||
|
break
|
||||||
|
return {"title": title, "row_count": row_count}
|
||||||
|
|
||||||
|
|
||||||
|
def _presentation_completion_answer(
|
||||||
|
mcp_result: Any,
|
||||||
|
agent_steps: list[Mapping[str, Any]] | None,
|
||||||
|
) -> str:
|
||||||
|
artifact = _presentation_artifact_summary(mcp_result, agent_steps)
|
||||||
|
if not artifact:
|
||||||
|
return ""
|
||||||
|
return (
|
||||||
|
f"**{artifact['title']}** 리포트를 생성했습니다. "
|
||||||
|
f"아래 생성된 리포트에서 조회 결과 {artifact['row_count']}건을 확인할 수 있습니다."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _mcp_has_actionable_result(mcp_result: Any) -> bool:
|
def _mcp_has_actionable_result(mcp_result: Any) -> bool:
|
||||||
if _mcp_generated_sql(mcp_result) or _mcp_items(mcp_result):
|
if _mcp_generated_sql(mcp_result) or _mcp_items(mcp_result):
|
||||||
return True
|
return True
|
||||||
@@ -3878,12 +3994,28 @@ def run_mcp_agent_loop(
|
|||||||
raise PublicMcpError("선택된 MCP 서버 설정을 찾지 못했습니다.")
|
raise PublicMcpError("선택된 MCP 서버 설정을 찾지 못했습니다.")
|
||||||
|
|
||||||
started = perf_counter()
|
started = perf_counter()
|
||||||
|
presentation_title = ""
|
||||||
|
if _is_presentation_route(route):
|
||||||
|
source_step = next(
|
||||||
|
(
|
||||||
|
step
|
||||||
|
for step in reversed(steps)
|
||||||
|
if not bool(step.get("consumes_previous_result"))
|
||||||
|
),
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
presentation_title = _plan_presentation_title(
|
||||||
|
question=question,
|
||||||
|
source_step=source_step,
|
||||||
|
model_profile_key=model_profile_key,
|
||||||
|
)
|
||||||
arguments = _build_mcp_arguments(
|
arguments = _build_mcp_arguments(
|
||||||
route=route,
|
route=route,
|
||||||
tool_query=question if _is_presentation_route(route) else tool_query,
|
tool_query=question if _is_presentation_route(route) else tool_query,
|
||||||
limit=int(limit),
|
limit=int(limit),
|
||||||
preferred_tool=server.default_tool,
|
preferred_tool=server.default_tool,
|
||||||
steps=steps,
|
steps=steps,
|
||||||
|
presentation_title=presentation_title,
|
||||||
)
|
)
|
||||||
raw_result = call_tool(
|
raw_result = call_tool(
|
||||||
base_url=server.endpoint_url,
|
base_url=server.endpoint_url,
|
||||||
@@ -4526,6 +4658,10 @@ def synthesize_answer(
|
|||||||
}
|
}
|
||||||
for step in (agent_steps or [])
|
for step in (agent_steps or [])
|
||||||
],
|
],
|
||||||
|
"presentation_artifact": _presentation_artifact_summary(
|
||||||
|
mcp_result,
|
||||||
|
agent_steps,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
return json.dumps(prompt_payload, ensure_ascii=False)
|
return json.dumps(prompt_payload, ensure_ascii=False)
|
||||||
|
|
||||||
@@ -4609,7 +4745,11 @@ def synthesize_answer(
|
|||||||
"Show audit_log_id when it is provided. Follow every required_answer_checks item in the user "
|
"Show audit_log_id when it is provided. Follow every required_answer_checks item in the user "
|
||||||
"payload. When the user requests a result list, include every "
|
"payload. When the user requests a result list, include every "
|
||||||
"row provided in the MCP evidence, up to 20 rows, and state the total "
|
"row provided in the MCP evidence, up to 20 rows, and state the total "
|
||||||
"returned row count. Do not arbitrarily stop at five rows. Keep the "
|
"returned row count. Do not arbitrarily stop at five rows. If "
|
||||||
|
"presentation_artifact is present, the HTML artifact is the final visual output: "
|
||||||
|
"do not reproduce HTML tags, Markdown tables, or individual data rows in answer. "
|
||||||
|
"Only confirm the artifact title and row count and direct the user to the rendered "
|
||||||
|
"report displayed below. Keep the "
|
||||||
"answer concise and business-readable."
|
"answer concise and business-readable."
|
||||||
)
|
)
|
||||||
for attempt in range(1, 3):
|
for attempt in range(1, 3):
|
||||||
@@ -4909,8 +5049,18 @@ def _render_assistant_message(
|
|||||||
message: Mapping[str, Any],
|
message: Mapping[str, Any],
|
||||||
message_key: str,
|
message_key: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
st.markdown(str(message.get("content") or ""))
|
|
||||||
details = message.get("details")
|
details = message.get("details")
|
||||||
|
content = str(message.get("content") or "")
|
||||||
|
if isinstance(details, Mapping):
|
||||||
|
presentation_answer = _presentation_completion_answer(
|
||||||
|
details.get("mcp_result", {}),
|
||||||
|
details.get("agent_steps")
|
||||||
|
if isinstance(details.get("agent_steps"), list)
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
if presentation_answer:
|
||||||
|
content = presentation_answer
|
||||||
|
st.markdown(content)
|
||||||
basis = message.get("basis")
|
basis = message.get("basis")
|
||||||
if isinstance(basis, list) and basis:
|
if isinstance(basis, list) and basis:
|
||||||
display_records = _display_markdown_records(
|
display_records = _display_markdown_records(
|
||||||
@@ -5647,6 +5797,13 @@ def _process_submitted_question(
|
|||||||
}
|
}
|
||||||
refresh_progress(96, "원본 MCP 응답으로 결과를 구성했습니다.")
|
refresh_progress(96, "원본 MCP 응답으로 결과를 구성했습니다.")
|
||||||
|
|
||||||
|
presentation_answer = _presentation_completion_answer(
|
||||||
|
answer_source,
|
||||||
|
agent_steps,
|
||||||
|
)
|
||||||
|
if presentation_answer:
|
||||||
|
assistant_message["content"] = presentation_answer
|
||||||
|
|
||||||
pre_save_elapsed = perf_counter() - process_started
|
pre_save_elapsed = perf_counter() - process_started
|
||||||
record_execution_event(
|
record_execution_event(
|
||||||
percent=98,
|
percent=98,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ast
|
import ast
|
||||||
|
import html
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
from typing import Any, Mapping
|
from typing import Any, Mapping
|
||||||
import unittest
|
import unittest
|
||||||
@@ -174,6 +176,41 @@ class DemoScenarioConfigTest(unittest.TestCase):
|
|||||||
self.assertEqual(len(rows), 2)
|
self.assertEqual(len(rows), 2)
|
||||||
self.assertEqual(rows[0]["CARRIER_CODE"], "C901")
|
self.assertEqual(rows[0]["CARRIER_CODE"], "C901")
|
||||||
|
|
||||||
|
def test_hmm_report_title_and_answer_follow_presentation_contract(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 == "_clean_presentation_title"
|
||||||
|
)
|
||||||
|
namespace: dict[str, Any] = {
|
||||||
|
"Any": Any,
|
||||||
|
"html": html,
|
||||||
|
"re": re,
|
||||||
|
}
|
||||||
|
exec(compile(ast.Module(body=[helper], type_ignores=[]), "app.py", "exec"), namespace)
|
||||||
|
|
||||||
|
title = namespace["_clean_presentation_title"](
|
||||||
|
"<b>E1001 팀 포트폴리오</b>"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(title, "E1001 팀 포트폴리오")
|
||||||
|
self.assertIn('"title": _clean_presentation_title(title)', source)
|
||||||
|
self.assertIn(
|
||||||
|
'assistant_message["content"] = presentation_answer',
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"presentation_answer = _presentation_completion_answer(",
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"do not reproduce HTML tags, Markdown tables",
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
|
||||||
def test_hmm_report_template_contains_only_dynamic_payload_slot(self) -> None:
|
def test_hmm_report_template_contains_only_dynamic_payload_slot(self) -> None:
|
||||||
template = (
|
template = (
|
||||||
Path(__file__).parents[1]
|
Path(__file__).parents[1]
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ DB를 다시 조회하거나 자연어를 해석하지 않는다.
|
|||||||
- 입력은 질문·답변 근거·조회 행으로 구성된 허용 JSON 계약이며, Oracle DB 함수는 템플릿에만 매핑한다.
|
- 입력은 질문·답변 근거·조회 행으로 구성된 허용 JSON 계약이며, Oracle DB 함수는 템플릿에만 매핑한다.
|
||||||
- 선사 실적 조회 MCP가 VPD를 적용한 데이터 접근 경계이고, 리포트 MCP는 표현 경계다.
|
- 선사 실적 조회 MCP가 VPD를 적용한 데이터 접근 경계이고, 리포트 MCP는 표현 경계다.
|
||||||
- 승인된 HTML 템플릿은 DB CLOB으로 버전 관리하고, 생성 HTML은 MCP 응답의 `html` 속성으로만 반환한다.
|
- 승인된 HTML 템플릿은 DB CLOB으로 버전 관리하고, 생성 HTML은 MCP 응답의 `html` 속성으로만 반환한다.
|
||||||
|
- 제목은 질문 전체 문장을 복사하지 않는다. 포털의 제목 생성 지침이 요청 대상과 업무 범위만 남긴 짧은 보고서 제목을 만들고 renderer에 전달한다.
|
||||||
|
- HTML artifact가 반환되면 일반 답변은 HTML 표나 코드를 반복하지 않고 생성 완료와 조회 건수만 안내한다. 실제 표현은 `생성된 리포트` 영역 하나에서 담당한다.
|
||||||
|
|
||||||
## 전체 흐름
|
## 전체 흐름
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,24 @@ Tool 이름, 사용자 코드나 예상 행 수를 조건으로 사용하지 않
|
|||||||
`generatedAt`, `requestedBy`, `question`, `answer`, `execution`, `evidence`, `limitation`을
|
`generatedAt`, `requestedBy`, `question`, `answer`, `execution`, `evidence`, `limitation`을
|
||||||
넣는다. 행에는 담당자·선사 식별자와 최신 KPI만 넣는다.
|
넣는다. 행에는 담당자·선사 식별자와 최신 KPI만 넣는다.
|
||||||
|
|
||||||
|
`report.title`은 사용자 질문 원문이 아니다. 포털이 모델에 다음 제목 계약을 지시해 만든 짧은
|
||||||
|
업무 제목이다.
|
||||||
|
|
||||||
|
- `HTML로 보여줘`, `리포트로 만들어줘`와 같은 출력 형식·행동 문구는 제거한다.
|
||||||
|
- 사용자·팀·업무 대상처럼 범위를 구분하는 식별자는 유지한다.
|
||||||
|
- 문장형 답변이 아니라 화면 머리글에 맞는 명사형 제목으로 만든다.
|
||||||
|
- 템플릿이 붙이는 고정 부제와 같은 문구를 반복하지 않는다.
|
||||||
|
|
||||||
|
제목 생성이 실패하면 전체 질문을 제목으로 사용하지 않고 짧은 일반 업무 제목으로 안전하게
|
||||||
|
대체한다. 이 규칙은 특정 사용자 코드나 조회 행 수를 조건으로 사용하지 않는다.
|
||||||
|
|
||||||
|
## 포털 표시 계약
|
||||||
|
|
||||||
|
renderer 응답에 유효한 `html` 또는 `rendered_html`이 있으면 HTML artifact가 최종 표현물이다.
|
||||||
|
포털의 일반 답변 생성 지침은 HTML 태그, Markdown 표, 업무 행 전체를 다시 만들지 않고 제목과
|
||||||
|
조회 건수, 아래 리포트 확인 안내만 반환한다. 포털은 같은 조건을 출력 후에도 검사해 모델이
|
||||||
|
HTML을 반환하더라도 안전한 짧은 안내문으로 정규화한다.
|
||||||
|
|
||||||
서버는 JSON 크기, 행 수, 문자열 길이, 숫자 형식을 제한하고, 템플릿에 주입할 JSON에서
|
서버는 JSON 크기, 행 수, 문자열 길이, 숫자 형식을 제한하고, 템플릿에 주입할 JSON에서
|
||||||
`</script>`를 이스케이프한다. payload는 저장하지 않는다.
|
`</script>`를 이스케이프한다. payload는 저장하지 않는다.
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,9 @@
|
|||||||
3. 실행 상세에서 첫 단계가 데이터 조회이고 두 번째 단계가 HTML 렌더링인지 확인한다.
|
3. 실행 상세에서 첫 단계가 데이터 조회이고 두 번째 단계가 HTML 렌더링인지 확인한다.
|
||||||
4. 결과 영역에 `생성된 리포트` iframe이 표시되고, 막대·상세 표가 첫 단계 `items`와 일치하는지
|
4. 결과 영역에 `생성된 리포트` iframe이 표시되고, 막대·상세 표가 첫 단계 `items`와 일치하는지
|
||||||
확인한다.
|
확인한다.
|
||||||
|
5. 일반 답변에는 `<table>`, `<div>` 또는 Markdown 표가 반복되지 않고, 생성 완료·제목·조회 건수만
|
||||||
|
표시되는지 확인한다.
|
||||||
|
6. 리포트 머리글이 질문 전체 문장이 아니라 출력 형식 문구를 제거한 짧은 업무 제목인지 확인한다.
|
||||||
|
|
||||||
대표 E1001 팀장 질의의 운영 회귀 기준은 현재 8건이다. 이 숫자는 검증 기준일 뿐 코드나 Tool
|
대표 E1001 팀장 질의의 운영 회귀 기준은 현재 8건이다. 이 숫자는 검증 기준일 뿐 코드나 Tool
|
||||||
인수에 고정하지 않는다. 첫 단계 원본에 행이 있는데 `reportJson.rows`가 0건이면 순차 실행 성공이
|
인수에 고정하지 않는다. 첫 단계 원본에 행이 있는데 `reportJson.rows`가 0건이면 순차 실행 성공이
|
||||||
|
|||||||
@@ -35,6 +35,31 @@
|
|||||||
**해결**: DB 템플릿 활성 상태와 custom Agent Tool의 반환 형식을 확인한다. 포털은 유효한 `html`
|
**해결**: DB 템플릿 활성 상태와 custom Agent Tool의 반환 형식을 확인한다. 포털은 유효한 `html`
|
||||||
또는 `rendered_html`만 sandboxed iframe으로 표시한다.
|
또는 `rendered_html`만 sandboxed iframe으로 표시한다.
|
||||||
|
|
||||||
|
## 일반 답변에 HTML 표가 반복되거나 글자가 잘 보이지 않음
|
||||||
|
|
||||||
|
**원인**: 최종 답변 모델이 사용자의 `HTML로 보여줘`를 artifact 생성이 아니라 답변 본문 형식
|
||||||
|
요청으로 해석해 `<table>` 또는 Markdown 표를 다시 작성했다.
|
||||||
|
|
||||||
|
**확인**: MCP 마지막 응답에 유효한 `html`이 있으면서 저장된 `answer`에도 HTML 태그나 표 행이
|
||||||
|
있는지 확인한다.
|
||||||
|
|
||||||
|
**해결**: 최종 답변 지침에 HTML artifact 우선 규칙을 적용하고, 출력 후 검사에서 일반 답변을
|
||||||
|
생성 완료·제목·조회 건수 안내로 정규화한다. iframe CSS나 조회 Tool은 변경하지 않는다.
|
||||||
|
|
||||||
|
**재발 방지**: renderer가 반환된 시나리오에서 일반 답변에 `<table>`과 Markdown 표가 없는지
|
||||||
|
회귀 테스트한다.
|
||||||
|
|
||||||
|
## 리포트 제목에 질문 전체 문장이 표시됨
|
||||||
|
|
||||||
|
**원인**: `report.title`에 사용자 질문을 그대로 복사했다.
|
||||||
|
|
||||||
|
**확인**: renderer 호출의 `reportJson.report.title`과 `question`이 완전히 같은지 확인한다.
|
||||||
|
|
||||||
|
**해결**: 제목 생성 지침으로 출력 형식·행동 문구를 제거한 짧은 명사형 제목을 만들고,
|
||||||
|
`report.question`에는 원문을 유지한다.
|
||||||
|
|
||||||
|
**재발 방지**: 제목과 원문 질문이 역할상 분리되고 제목 길이 제한이 적용되는지 검증한다.
|
||||||
|
|
||||||
## 리포트는 생성됐지만 행이 0건임
|
## 리포트는 생성됐지만 행이 0건임
|
||||||
|
|
||||||
**원인**: 다음 둘 중 하나다.
|
**원인**: 다음 둘 중 하나다.
|
||||||
|
|||||||
Reference in New Issue
Block a user