Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 35 additions & 14 deletions agent/src/agentModeParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,41 @@ export const parseModeResult = (
rawText: string,
request: AgentServiceRequest,
): ParsedAgentModeResult => {
// qa mode는 자연어 한국어 답변(free-form markdown)이 계약상 허용된다.
// (docs/07-pi-agent-contracts.md: "QA 출력: JSON 강제 없음")
// Solar/Pi가 plain text를 반환하면 answerMarkdown으로 wrapping해 처리한다.
if (mode === "qa") {
let parsed: unknown;
try {
parsed = JSON.parse(rawText);
} catch {
// plain text fallback — wrap into qa JSON contract
parsed = { answerMarkdown: rawText.trim() };
}
if (!isObject(parsed)) {
parsed = { answerMarkdown: rawText.trim() };
}
const warnings = stringArray((parsed as Record<string, unknown>).warnings);
const parsedChecklist = Array.isArray(
(parsed as Record<string, unknown>).checklist,
)
? ((parsed as Record<string, unknown>).checklist as unknown[])
: undefined;
const analysisGuard = analysisGuardFrom(request, parsedChecklist);
const answerMarkdown = requireString(
(parsed as Record<string, unknown>).answerMarkdown,
{ fieldName: "answerMarkdown" },
);
return {
markdown: answerMarkdown,
warnings,
data: {
qa: { answerMarkdown },
analysisGuard,
},
};
}

let parsed: unknown;
try {
parsed = JSON.parse(rawText);
Expand All @@ -115,20 +150,6 @@ export const parseModeResult = (
: undefined;
const analysisGuard = analysisGuardFrom(request, parsedChecklist);

if (mode === "qa") {
const answerMarkdown = requireString(parsed.answerMarkdown, {
fieldName: "answerMarkdown",
});
return {
markdown: answerMarkdown,
warnings,
data: {
qa: { answerMarkdown },
analysisGuard,
},
};
}

if (mode === "checklist_explanation") {
const summaryMarkdown = requireString(parsed.summaryMarkdown, {
fieldName: "summaryMarkdown",
Expand Down
19 changes: 15 additions & 4 deletions agent/test/agentServiceModeContract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,24 @@ test("parseModeResult returns structured checklist explanation payload", () => {
);
});

test("parseModeResult rejects malformed non-json mode output", () => {
test("parseModeResult wraps plain-text qa response as answerMarkdown (free-form contract)", () => {
// docs/07: "QA 출력: JSON 강제 없음 — 자연어 한국어 답변"
// Solar/Pi가 plain text를 반환하면 answerMarkdown으로 wrapping해 처리한다.
const parsed = parseModeResult("qa", "안녕하세요, 저 공시리입니다.", {
...baseRequest,
mode: "qa",
question: "안녕",
});
assert.equal(parsed.data.qa.answerMarkdown, "안녕하세요, 저 공시리입니다.");
assert.equal(parsed.markdown, "안녕하세요, 저 공시리입니다.");
});

test("parseModeResult rejects malformed non-json output for non-qa modes", () => {
assert.throws(
() =>
parseModeResult("qa", "not-json", {
parseModeResult("report", "not-json", {
...baseRequest,
mode: "qa",
question: "질문",
mode: "report",
}),
/JSON/,
);
Expand Down
2 changes: 1 addition & 1 deletion backend/agent_qa_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def answer_qa_with_agent(
analysis_result: Any,
trace_id: str,
contract_version: str,
conversation_key: dict[str, str] | None = None,
conversation_key: str | None = None,
prior_turns: list[dict[str, Any]] | None = None,
client: AgentServiceClient | None = None,
) -> dict[str, Any]:
Expand Down
4 changes: 2 additions & 2 deletions backend/routes/qa_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ async def qa_route(request: Request):
company = bundle_payload.get("company") if isinstance(bundle_payload, dict) else {}
effective_corp_code = str((company or {}).get("corp_code") or "")

conversation_key: dict | None = None
conversation_key: str | None = None
prior_turns: list[dict] = []
if effective_corp_code:
raw_turns = await run_in_threadpool(
Expand All @@ -101,7 +101,7 @@ async def qa_route(request: Request):
for row in raw_turns:
prior_turns.append({"role": "user", "content": str(row.get("question") or "")})
prior_turns.append({"role": "assistant", "content": str(row.get("answer") or "")})
conversation_key = {"userId": user_id, "corpCode": effective_corp_code}
conversation_key = f"{user_id}::{effective_corp_code}"

answer = await run_in_threadpool(
answer_qa_with_agent,
Expand Down
8 changes: 5 additions & 3 deletions backend/tests/routes/test_qa_routes_prior_turns.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,11 +239,13 @@ def spy_answer_qa(

assert resp.status_code == 200, f"expected 200, got {resp.status_code}: {resp.text}"

# conversation_key must be present with userId and corpCode
# conversation_key must be a non-empty string in "${user_id}::${corp_code}" format
assert captured.get("conversation_key") is not None, "conversation_key should be set"
conv_key = captured["conversation_key"]
assert "userId" in conv_key, f"conversation_key missing userId: {conv_key}"
assert conv_key["corpCode"] == "00258801", f"wrong corpCode in conversation_key: {conv_key}"
assert isinstance(conv_key, str) and "::" in conv_key, (
f"conversation_key should be 'user_id::corp_code' string, got: {conv_key!r}"
)
assert conv_key.endswith("::00258801"), f"wrong corpCode in conversation_key: {conv_key}"

# prior_turns should contain 4 items: 2 turns × {role:user, role:assistant}
prior = captured.get("prior_turns")
Expand Down
208 changes: 208 additions & 0 deletions frontend/e2e/demo.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { test, expect } from "@playwright/test";

/**
* 공시리 데모 시나리오 — 4단계 한 사이클 녹화
*
* 1. 로그인 (admin/admin)
* 2. 워치리스트에 SK하이닉스 추가
* 3. 리포트 목록에서 삼성전자 재분석
* 4. QA에서 삼성전자 질의
*/

const SAMSUNG_CORP_CODE = "00126380";
const SKHYNIX_CORP_CODE = "00164779";

Check warning on line 13 in frontend/e2e/demo.spec.ts

View workflow job for this annotation

GitHub Actions / js-lint

'SKHYNIX_CORP_CODE' is assigned a value but never used

test("공시리 데모 시나리오", async ({ page }) => {
// ──────────────────────────────────────────
// 1단계: 로그인
// ──────────────────────────────────────────
await page.goto("/login");
await expect(page)
.toHaveTitle(/공시리|gongsiri/i, { timeout: 10_000 })
.catch(() => {});

// 로그인 폼: admin/admin 기본값이 이미 세팅돼 있지만 명시적으로 채움
await page.locator('input[autocomplete="username"]').fill("admin");
await page.locator('input[autocomplete="current-password"]').fill("admin");
await page.waitForTimeout(500);
await page.locator('button[type="submit"]').click();

// 대시보드로 리다이렉트 대기
await page.waitForURL("**/dashboard", { timeout: 15_000 });
await page.waitForTimeout(1000);

// ──────────────────────────────────────────
// 2단계: 워치리스트에 SK하이닉스 추가
// ──────────────────────────────────────────
await page.goto("/watchlist");
await page.waitForLoadState("networkidle");
await page.waitForTimeout(800);

// "종목 추가" 버튼 클릭 — 텍스트로 찾기
const addButton = page.getByRole("button", { name: "종목 추가" });
await addButton.waitFor({ state: "visible", timeout: 10_000 });
await addButton.click();
await page.waitForTimeout(600);

// 모달 내 검색창에 "하이닉스" 입력 (type()으로 React onChange 트리거)
const searchInput = page.locator(
'input[placeholder="종목명 또는 코드 검색"]',
);
await searchInput.waitFor({ state: "visible", timeout: 8_000 });
await searchInput.click();
await searchInput.type("하이닉스", { delay: 80 });
await page.waitForTimeout(2000);

// 검색 결과에서 에스케이하이닉스보통주 선택
const hynixRow = page
.locator("button")
.filter({ hasText: /에스케이하이닉스/ })
.first();
await hynixRow.waitFor({ state: "visible", timeout: 15_000 });
await hynixRow.click();
await page.waitForTimeout(600);

// 선택 확인 후 "종목 추가" 버튼 클릭
// 모달 내 "종목 추가" 버튼 (두 번째 또는 마지막 버튼)
const modalAddButton = page
.locator("button")
.filter({ hasText: /^종목 추가$/ })
.last();
await modalAddButton.waitFor({ state: "visible", timeout: 8_000 });
await modalAddButton.click();
await page.waitForTimeout(1500);

// 등록 완료 메시지 또는 이미 있음 메시지 대기
await page.waitForTimeout(1000);

// 모달 닫힘 대기 (모달이 사라지거나 워치리스트 목록에 반영)
await page.waitForTimeout(1200);

// ──────────────────────────────────────────
// 3단계: 리포트 목록에서 삼성전자 재분석
// ──────────────────────────────────────────
await page.goto("/report");
await page.waitForLoadState("networkidle");
await page.waitForTimeout(1000);

// 삼성전자 행 또는 리포트 항목 클릭 → 상세 페이지 이동
// 삼성전자 링크가 있으면 클릭, 없으면 직접 URL 이동
const samsungLink = page
.locator("a")
.filter({ hasText: /삼성전자/ })
.first();
const samsungLinkCount = await samsungLink.count();

if (samsungLinkCount > 0) {
await samsungLink.click();
} else {
// 삼성전자 행이 없으면 직접 리포트 상세 URL로 이동
await page.goto(`/report/${SAMSUNG_CORP_CODE}`);
}

await page.waitForLoadState("networkidle");
await page.waitForTimeout(1000);

// "재분석" 버튼 클릭
const reanalyzeButton = page.getByRole("button", { name: /재분석/ });
await reanalyzeButton.waitFor({ state: "visible", timeout: 15_000 });
await reanalyzeButton.click();
await page.waitForTimeout(2000);

// 리포트 생성 완료 대기 (최대 90초)
// 로딩 스피너 또는 분석 중 메시지가 사라질 때까지 대기
const analysisComplete = page

Check warning on line 114 in frontend/e2e/demo.spec.ts

View workflow job for this annotation

GitHub Actions / js-lint

'analysisComplete' is assigned a value but never used
.locator('[data-risk-level], .checklist, [class*="checklist"]')
.first();

// 90초 내에 결과가 렌더링되길 기다림
// 버튼 상태가 복귀하거나 체크리스트 항목이 보이면 완료
try {
await page.waitForSelector("[data-risk-level], h2, h3", {
timeout: 90_000,
});
} catch {
// 타임아웃이어도 계속 진행
}
await page.waitForTimeout(2000);

// 페이지 스크롤 다운 — 6개 항목 + 단기/장기 리포트 확인
await page.evaluate(() => window.scrollTo({ top: 400, behavior: "smooth" }));
await page.waitForTimeout(1000);
await page.evaluate(() => window.scrollTo({ top: 800, behavior: "smooth" }));
await page.waitForTimeout(1000);
await page.evaluate(() => window.scrollTo({ top: 1200, behavior: "smooth" }));
await page.waitForTimeout(1000);

// ──────────────────────────────────────────
// 4단계: QA에서 삼성전자 질의 (multi-turn)
// ──────────────────────────────────────────
await page.goto("/qa");
await page.waitForLoadState("networkidle");

// A. reload → DemoSessionProvider 재마운트 → qaStockOptions refetch
await page.reload();
await page.waitForLoadState("networkidle");

// B. select options 채워질 때까지 명시적 대기
await page.waitForFunction(
() => {
const sel = document.querySelector("select");
return sel !== null && sel.options.length > 0;
},
{ timeout: 15_000 },
);

// C. 삼성전자 선택 (value = corp_code), fallback: 라벨 텍스트 매칭
const corpSelect = page.locator("select").first();
await corpSelect
.selectOption({ value: SAMSUNG_CORP_CODE })
.catch(async () => {
const opts = await corpSelect.locator("option").allTextContents();
const match = opts.find((t) => /삼성전자/.test(t));
if (match) await corpSelect.selectOption({ label: match });
});
await page.waitForTimeout(800);

// D. QA input — placeholder가 종목명 기반으로 동적으로 바뀜, "에 대해 질문하세요" 포함
const qaInput = page.locator('input[placeholder*="에 대해 질문하세요"]');
await qaInput.waitFor({ state: "visible", timeout: 10_000 });

// 1턴: 첫 번째 질문 (Enter로 전송)
await qaInput.fill("삼성전자의 최근 위험 시그널은 뭔가요?");
await qaInput.press("Enter");

// E. assistant 응답 도착 대기 — 화면에 '삼성전자' 텍스트 + 충분한 응답 길이 확인
await page.waitForFunction(
() => {
const text = document.body.innerText;
return text.includes("삼성전자") && text.length > 200;
},
{ timeout: 90_000 },
);
await page.waitForTimeout(1500);

// F. 2턴째 질문
await qaInput.fill("단기적으로 가장 위험한 신호는 뭔가요?");
await qaInput.press("Enter");

// 2턴 응답 대기 — 1턴보다 본문이 더 길어졌음을 확인
const bodyAfterTurn1 = await page.evaluate(
() => document.body.innerText.length,
);
await page.waitForFunction(
(prevLen: number) => document.body.innerText.length > prevLen + 50,
bodyAfterTurn1,
{ timeout: 90_000 },
);
await page.waitForTimeout(1500);

// G. 스크롤 다운 후 2초 hold
await page.evaluate(() =>
window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" }),
);
await page.waitForTimeout(2000);

// 데모 완료 — 최종 화면 2초 유지
await page.waitForTimeout(2000);
});
Loading