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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,6 @@ packages/agent/data/corpus/*.summary.json

# Local browser QA artifacts
.playwright-mcp/

# Agent run observability traces (TRACE_DIR)
packages/agent/runs/
2 changes: 1 addition & 1 deletion docs/specs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@
- **Closes**: Q-6

### D-6. 클라이언트 ↔ agent 프로토콜은 SSE
- **결정**: Hono `streamSSE` + 클라이언트 `EventSource`. `POST /api/generate`가 `text/event-stream`으로 응답. event 종류: `step` (단계 시작/완료), `result` (최종 문제 묶음), `error` (스테이지·메시지).
- **결정**: Hono `streamSSE` + 클라이언트 `EventSource`. `POST /api/generate`가 `text/event-stream`으로 응답. event 종류: `step` (단계 시작/완료/판정불가 + 단계 서사 summary), `preview` (3단계 직후 후보 미리보기), `attempt` (재생성 시작 — attempt/max_attempts/reason), `runs` (병렬 런 집계), `result` (최종 문제 묶음), `error` (스테이지·메시지). 상세 wire 계약: `packages/web/README.md` §SSE Consumption.
- **대안**: (a) sync REST + 폴링 (step bar 라이브 노출 안됨), (b) job 큐 + 폴링/웹훅 (다중 사용자 동시성 1차 MVP scope 초과), (c) WebSocket (양방향 불필요).
- **채택 사유**: D-4 `streamText` chunk + D-5 async generator emit이 SSE와 1:1 매칭. 단방향 progress 스트리밍 표준. 프론트 `EventSource` 빌트인.
- **Closes**: Q-3
Expand Down
15 changes: 12 additions & 3 deletions packages/agent/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ LLM_BASE_URL=http://localhost:8317/v1
LLM_API_KEY=dummy-key
LLM_MODEL=gpt-5.5(xhigh)

# (옵션) 독립 재풀이(D-5 ReSolveSpecialist) 전용 모델. 생성과 다른 모델을 쓰면
# 동일 오답 재현 가능성이 낮아짐. 미설정 시 LLM_MODEL 과 동일한 인스턴스를 공유.
# SOLVER_MODEL=claude-sonnet-4.5
# (권장) 독립 재풀이(D-5 ReSolveSpecialist) 전용 모델. 생성과 다른 모델을 쓰면
# 동일 오답 재현 가능성이 낮아짐. 미설정 시 LLM_MODEL 과 동일한 인스턴스를 공유 —
# 이러면 "독립" 재풀이의 교차검증 효과가 사라지므로 반드시 분리할 것.
# 현재 cliproxy 가 노출하는 모델 중에서는 gpt-5.4 가 생성기(gpt-5.5)와 다른 체크포인트.
# Claude/Gemini 계열이 프록시에 생기면 다른 벤더 모델로 교체 권장.
SOLVER_MODEL=gpt-5.4

# --- 관측성 (DX) ---
# run 단위 JSONL 트레이스: ProgressEvent 전문(게이트 evidence 포함) + LLM 호출 지연/토큰.
# tail -f runs/run-*.jsonl | jq 로 에이전트 내부를 실시간 관찰 가능.
TRACE_ENABLED=true
TRACE_DIR=./runs
56 changes: 43 additions & 13 deletions packages/agent/prompts/refiner.md
Original file line number Diff line number Diff line change
@@ -1,41 +1,71 @@
---
id: refiner
version: 0.1.0
version: 0.2.0
model: gpt-4o
temperature: 0.5
max_tokens: 2000
schema: GeneratedProblemSchema
variables:
- prior
- request
- generationKind
- intent
- refs
- strategy
- hints
- schemaError
owner: 비할당
updated: 2026-05-18
updated: 2026-06-11
---

# Role

이전 생성된 문제를 *비평 힌트*에 따라 다시 작성하라. Intent는 유지하되 표면 표현만 개선.
당신은 한국 중학교 수학 문제의 *수선공*이다. 이전에 생성된 문제(Prior)를 아래 비평 힌트에 따라 고쳐 쓰라.

- 비평이 지적한 부분만 고치고, 문제의 골격·소재·풀이 경로는 Prior에 최대한 가깝게 유지한다.
- 완전히 다른 문제를 새로 만들지 않는다. Prior와 동떨어진 문제는 실패로 간주된다.
- 학습 목표와 평가 차원(Intent)은 절대 바꾸지 않는다.

# Prior

{{prior.question_text}}
문제: {{prior.question_text}}
정답: {{prior.expected_answer}}
풀이: {{prior.proposed_solution_trace}}

# Critique hints (이 지적들을 반드시 해소하라)

{{#each hints}}- {{this}}
{{/each}}

# Intent (불변)

{{intent.objective_description}}
보존해야 할 평가 차원: {{intent.evaluation_dimensions}}
학습 목표: {{intent.objective_description}} (`{{intent.objective_code}}`)
보존해야 할 평가 차원: {{#each intent.evaluation_dimensions}}{{#if this.must_preserve}}{{this.description}}; {{/if}}{{/each}}
필수 기법: {{intent.required_techniques}}
금지 기법: {{intent.forbidden_techniques}}

# Critique hints
# Generation Kind

{{#each hints}}- {{this}}
{{/each}}
`{{generationKind}}` 유형을 유지하라. `{{request.difficulty}}` 난이도, `{{request.problem_type}}` 형식.

# Output
# Strategy (선택)

{{strategy}}

{{#if schemaError}}
# Schema repair hint (즉시 재시도)

다시 작성된 `GeneratedProblem` JSON. `inferred_intent.objective_code`와 `mode`는 prior 그대로 유지.
직전 응답이 JSON schema 검증에 실패했다. 오류: {{schemaError}}

- 반드시 JSON object 하나만 출력하라.
- `question_text`, `expected_answer`, `techniques_used`, `proposed_solution_trace` 네 필드를 모두 채워라.
{{/if}}

# Output

# TODO
JSON으로만 응답. 필드: `question_text`, `expected_answer`, `techniques_used`, `proposed_solution_trace`.

- prior와의 *변경 범위* 제약 (너무 다른 문제로 변하지 않게)
- JSON 문자열 안에서 raw backslash를 쓰지 말 것. `\(`, `\sqrt` 같은 LaTeX 명령 금지.
- 수식은 JSON 안전한 plain text로: 지수는 `x**2`, 곱셈은 `5*x`, 제곱근은 `sqrt(7)`.
- `expected_answer`는 정답만 간결하게. 수정 후에도 문제와 정답이 일치하는지 반드시 재검산하라.
- `techniques_used`는 실제 사용한 snake_case 기법 id 배열.
73 changes: 50 additions & 23 deletions packages/agent/src/agents/generator-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export interface GeneratorAgentDeps {
prompts: PromptLoader;
}

const LlmGeneratedCandidateSchema = z.object({
export const LlmGeneratedCandidateSchema = z.object({
question_text: z
.string()
.min(1)
Expand All @@ -56,7 +56,41 @@ const LlmGeneratedCandidateSchema = z.object({
.describe("Korean solution trace explaining the structural/conceptual transform"),
});

type LlmGeneratedCandidate = z.infer<typeof LlmGeneratedCandidateSchema>;
export type LlmGeneratedCandidate = z.infer<typeof LlmGeneratedCandidateSchema>;

/** LLM raw output + 호출 컨텍스트 → 도메인 GeneratedProblem. Generator와 Refiner가 공유. */
export function assembleGeneratedProblem(input: {
readonly request: GenerateRequest;
readonly intent: Intent;
readonly refs: RagResult[];
readonly attempt: number;
readonly object: LlmGeneratedCandidate;
readonly modelId: string;
readonly temperature: number;
readonly promptId: string;
readonly promptVersion: string;
}): GeneratedProblem {
const generationKind = generationKindForTopic(getGenerateRequestTopicCode(input.request));
return {
candidate_id: randomUUID(),
mode: input.request.mode === "conceptual" ? "conceptual" : "structural",
generation_kind: generationKind,
question_text: input.object.question_text,
expected_answer: input.object.expected_answer,
techniques_used: input.object.techniques_used ?? [],
proposed_solution_trace: input.object.proposed_solution_trace,
source_refs: input.refs.map((ref) => ref.item_id),
inferred_intent: input.intent,
generation_metadata: {
model: input.modelId,
temperature: input.temperature,
prompt_id: input.promptId,
prompt_version: input.promptVersion,
attempt: input.attempt,
generated_at: new Date().toISOString(),
},
};
}

export function temperatureForGeneratorAttempt(
attempt: number,
Expand Down Expand Up @@ -95,38 +129,31 @@ export function createGeneratorAgent(deps: GeneratorAgentDeps): GeneratorAgent {
},
});

return {
candidate_id: randomUUID(),
mode: input.request.mode === "conceptual" ? "conceptual" : "structural",
generation_kind: generationKind,
question_text: object.question_text,
expected_answer: object.expected_answer,
techniques_used: object.techniques_used ?? [],
proposed_solution_trace: object.proposed_solution_trace,
source_refs: input.refs.map((ref) => ref.item_id),
inferred_intent: input.intent,
generation_metadata: {
model: deps.modelId,
temperature,
prompt_id: prompt.metadata.id,
prompt_version: prompt.metadata.version,
attempt: input.attempt,
generated_at: new Date().toISOString(),
},
};
return assembleGeneratedProblem({
request: input.request,
intent: input.intent,
refs: input.refs,
attempt: input.attempt,
object,
modelId: deps.modelId,
temperature,
promptId: prompt.metadata.id,
promptVersion: prompt.metadata.version,
});
},
};
}

interface GenerateCandidateObjectInput {
export interface GenerateCandidateObjectInput {
model: LanguageModel;
prompt: string;
temperature: number;
signal?: AbortSignal;
retryPromptForSchemaError(schemaError: string): string;
}

async function generateCandidateObject(
/** generateObject + schema-repair 1회 재시도. Generator와 Refiner가 공유. */
export async function generateCandidateObject(
input: GenerateCandidateObjectInput,
): Promise<LlmGeneratedCandidate> {
try {
Expand Down
48 changes: 35 additions & 13 deletions packages/agent/src/agents/refiner-agent.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
/** RefinerAgent — D-5 GenerationSpecialist team. Re-runs GeneratorAgent with critique hints. */
/** RefinerAgent — D-5 GenerationSpecialist team. 비평 힌트를 받아 prior 후보를 *수선*한다.
* Generator의 백지 재생성과 달리 자기 프롬프트(refiner.md)로 변경 범위를 prior 근처로 제한. */

import type { LanguageModel } from "ai";

import type { GenerateRequest, GeneratedProblem, Intent, RagResult, Strategy } from "../schemas/index.js";
import type { GeneratorAgent } from "./generator-agent.js";
import { generationKindForTopic, getGenerateRequestTopicCode } from "../schemas/index.js";
import type { PromptLoader } from "../tools/prompt-loader.js";
import {
assembleGeneratedProblem,
generateCandidateObject,
} from "./generator-agent.js";

export interface RefineInput {
prior: GeneratedProblem;
Expand All @@ -24,27 +30,43 @@ export interface RefinerAgentDeps {
model: LanguageModel;
modelId: string;
promptId: string;
generator: GeneratorAgent;
prompts: PromptLoader;
}

export function createRefinerAgent(deps: RefinerAgentDeps): RefinerAgent {
return {
async refine(input) {
const refinementHint = [
"Prior candidate:",
input.prior.question_text,
"Critique hints:",
...input.hints.map((hint) => `- ${hint}`),
].join("\n");
const prompt = await deps.prompts.load(deps.promptId);
const generationKind = generationKindForTopic(getGenerateRequestTopicCode(input.request));
const basePromptVars = {
prior: input.prior,
request: input.request,
generationKind,
intent: input.intent,
refs: input.refs,
strategy: input.strategy === null ? "" : JSON.stringify(input.strategy, null, 2),
hints: input.hints,
};
const object = await generateCandidateObject({
model: deps.model,
prompt: prompt.render(basePromptVars),
temperature: prompt.metadata.temperature,
signal: input.signal,
retryPromptForSchemaError(schemaError) {
return prompt.render({ ...basePromptVars, schemaError });
},
});

return deps.generator.generate({
return assembleGeneratedProblem({
request: input.request,
intent: input.intent,
refs: input.refs,
strategy: input.strategy,
attempt: input.attempt,
refinementHint,
signal: input.signal,
object,
modelId: deps.modelId,
temperature: prompt.metadata.temperature,
promptId: prompt.metadata.id,
promptVersion: prompt.metadata.version,
});
},
};
Expand Down
10 changes: 8 additions & 2 deletions packages/agent/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,18 @@ export const EnvSchema = z.object({
* workflow. Below ~45s, last-resort/off generation times out and fails. */
PER_STEP_TIMEOUT_MS: z.coerce.number().int().min(1000).default(60000),

/** `first` = template short-circuits LLM when refs exist (current behavior).
/** `first` = template short-circuits LLM when refs exist.
* `off` = always go through LLM generator path; never substitute template.
* `last-resort` = placeholder, currently behaves like `first` (see TODO 1-1a). */
* `last-resort` = LLM first; substitute the deterministic template only when the
* final verification verdict is `rejected` (transparent fallback, D-11). */
DETERMINISTIC_FALLBACK: z
.enum(["off", "last-resort", "first"])
.default("first"),

/** Per-run JSONL observability traces (ProgressEvent 전문 + LLM call 지연/토큰).
* Files land in TRACE_DIR: run-<date>-<id>.jsonl, llm.jsonl. */
TRACE_ENABLED: z.enum(["true", "false"]).default("true"),
TRACE_DIR: z.string().default("./runs"),
});

export type Env = z.infer<typeof EnvSchema>;
Expand Down
Loading
Loading