feat(notify): junior 가 LLM 응답에 직접 discord 한 줄 멘트 emit (옵션 C)
자기야 결정: 옵션 C — junior 가 자기 work 끝에 ```discord-line``` 블록을 출력하고, sister-agent 가 그걸 추출해서 stage-end Discord notify 메시지로 사용. 추가 LLM 호출 0, 매번 다른 메시지, 자매 본인이 자기가 한 일을 직접 보고하는 느낌. ## 변경 ### prompts.ts - buildPrompt 의 footer 에 role==='junior' 분기 추가 - 새 helper discordLineFooter(ctx): 자매별 페르소나 + stage 별 예시 포함 - 출력 형식 강제: ```discord-line\n<한 줄>\n``` - 50 자 이내, 이모지 1-2 개, 실패시 ✗/⚠️/🛑 명시 지시 ### discord-notify.ts - StageMessageContext.customLine 필드 추가 - extractDiscordLines(text): 모든 ```discord-line``` 블록 추출 + 본문에서 strip → { lines, cleaned } - renderStageEnd 가 customLine 우선, 없으면 hardcoded 풀로 fallback ### spawn.ts - aggregated 만들기 전에 extractDiscordLines() 호출 - cleaned 텍스트만 buildSuccessResult / priorStages 에 전달 (chat noise 가 다음 stage 의 LLM context 로 새지 않게) - 첫 번째 추출된 line 을 customLine 으로 renderStageEnd 에 전달 ## 효과 이전: 다랑이가 항상 "🔍 리뷰 통과! 이랑이 받아" 같은 4 variant pool 에서 픽 → 식상 + 작업 내용 무관 이후: 다랑이가 직접 LLM 응답 끝에 emit - "🔍 체크리스트 다 ✓. addTodo/toggleTodo/deleteTodo 모두 동작. 이랑이 받아." - "⚠️ addTodo 가 빈 입력 처리 못 함. 나랑아 다시 봐줘." LLM 이 자기가 본 코드의 실제 결함을 한 줄로 요약. 같은 stage 라도 매번 다른 메시지. fallback 은 그대로 작동.
This commit is contained in:
@@ -102,6 +102,43 @@ export interface StageMessageContext {
|
||||
verdict?: string;
|
||||
childCount?: number;
|
||||
filesProduced?: number;
|
||||
/**
|
||||
* Optional custom line provided by the LLM itself (extracted from a
|
||||
* `discord-line` code block in the junior output). When set, this line
|
||||
* is used verbatim instead of picking from the hardcoded pool. Falls
|
||||
* back to the pool if empty / undefined.
|
||||
*/
|
||||
customLine?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull every ```discord-line\n<line>\n``` block out of an LLM text blob.
|
||||
* Returns the extracted lines AND the original text with all such blocks
|
||||
* removed (so it can be safely passed to downstream stages without chat
|
||||
* noise polluting their priorStages context).
|
||||
*
|
||||
* The block is intentionally a fenced code block so it doesn't conflict
|
||||
* with regular markdown formatting and is easy for the LLM to emit
|
||||
* verbatim.
|
||||
*/
|
||||
export function extractDiscordLines(text: string): {
|
||||
lines: string[];
|
||||
cleaned: string;
|
||||
} {
|
||||
if (!text) return { lines: [], cleaned: text };
|
||||
// Match: ```discord-line<newline><single-line content><newline>```
|
||||
const pattern = /```discord-line\s*\n([^\n`]*)\n```/g;
|
||||
const lines: string[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = pattern.exec(text)) !== null) {
|
||||
const line = (m[1] ?? "").trim();
|
||||
if (line) lines.push(line);
|
||||
}
|
||||
const cleaned = text
|
||||
.replace(/```discord-line\s*\n[^\n`]*\n```/g, "")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
return { lines, cleaned };
|
||||
}
|
||||
|
||||
const START_POOLS: Record<string, string[]> = {
|
||||
@@ -244,6 +281,15 @@ export function renderStageEnd(ctx: StageMessageContext): string {
|
||||
ctx.filesProduced && ctx.filesProduced > 0
|
||||
? ` (산출물 ${ctx.filesProduced}개)`
|
||||
: "";
|
||||
|
||||
// LLM-supplied custom line wins. The junior who actually did the work
|
||||
// already knows what to say — use it verbatim. (We still substitute
|
||||
// {tail} in case the LLM left the placeholder in.)
|
||||
if (ctx.customLine && ctx.customLine.trim().length > 0) {
|
||||
return ctx.customLine.trim().replace("{tail}", tail);
|
||||
}
|
||||
|
||||
// Otherwise fall back to the hardcoded persona pool.
|
||||
const key = endPoolKey(ctx.agentName, ctx.stage, ctx.verdict);
|
||||
const pool = END_POOLS[key];
|
||||
if (!pool) return `✅ ${ctx.stage} 완료${tail}`;
|
||||
|
||||
@@ -89,9 +89,63 @@ export function buildPrompt(ctx: PromptContext): string {
|
||||
lines.push(roleOutputHint(ctx.role, ctx.stage));
|
||||
lines.push(`반드시 한국어로 답해. 핵심만 간결하게.`);
|
||||
|
||||
// 모든 작업 결과 끝에 디스코드용 한 줄 멘트를 LLM 이 직접 emit 하게 한다.
|
||||
// sister-agent 가 이 블록을 추출해 stage-end Discord notify 메시지로 사용
|
||||
// 한다 (없으면 hardcoded 풀로 fallback). junior 가 가장 작업 내용을 잘
|
||||
// 알기 때문에 junior 에만 요청한다 — manager 는 작업 시작 전에 결정만 함.
|
||||
if (ctx.role === "junior") {
|
||||
lines.push("");
|
||||
lines.push(discordLineFooter(ctx));
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Footer instructing the junior LLM to append a `discord-line` block at
|
||||
* the end of its response. The block is parsed by the sister-agent and
|
||||
* used as the stage-end Discord notification message.
|
||||
*
|
||||
* Persona context (자매 정체성) is included so the LLM matches tone:
|
||||
* harang — 차분/단정
|
||||
* narang — 활달/실용
|
||||
* darang — 꼼꼼/엄격
|
||||
* erang — 차분/믿음직
|
||||
*/
|
||||
function discordLineFooter(ctx: PromptContext): string {
|
||||
const persona: Record<string, string> = {
|
||||
harang: "차분하고 단정한 plan 단계 부장",
|
||||
narang: "활달하고 실용적인 implement 단계 부장",
|
||||
darang: "꼼꼼하고 엄격한 review 단계 부장",
|
||||
erang: "차분하고 믿음직한 deploy 단계 부장",
|
||||
};
|
||||
const exampleByStage: Record<string, string> = {
|
||||
plan: '"📋 MVP 범위 잡았어. 나랑이 받아."',
|
||||
implement: '"🔨 todo HTML 5개 함수 박았어. 다랑아 봐줘."',
|
||||
review:
|
||||
'"🔍 체크리스트 다 ✓. 이랑이 받아." (APPROVE) 또는 ' +
|
||||
'"⚠️ addTodo 가 빈 입력 처리 못 함. 나랑아 다시 봐줘." (REQUEST_CHANGES)',
|
||||
deploy: '"🚀 todo-mvp.html 검증 완료. 안전해."',
|
||||
};
|
||||
return [
|
||||
`# 디스코드 알림 한 줄`,
|
||||
`자기야가 디스코드 채널에서 보게 될 너의 한 줄 보고를 마지막에 추가해.`,
|
||||
`너는 ${persona[ctx.agentName] ?? ctx.agentName} 의 페르소나를 살려.`,
|
||||
`방금 너가 한 작업의 핵심을 한 줄로 요약 (50 자 이내, 이모지 1-2개).`,
|
||||
`결과가 실패/REQUEST_CHANGES/ABORT 면 그 사실을 명확히 (✗/⚠️/🛑 중 하나) 표시.`,
|
||||
``,
|
||||
`**정확히 다음 형식으로** 응답 맨 끝에 추가:`,
|
||||
"```discord-line",
|
||||
"<여기에 한 줄>",
|
||||
"```",
|
||||
``,
|
||||
`예시 (${STAGE_KOREAN[ctx.stage]}):`,
|
||||
exampleByStage[ctx.stage] ?? '"✅ 작업 완료"',
|
||||
``,
|
||||
`이 블록은 따로 파싱되니까 위 형식 정확히 지켜. 본문 어디 다른 곳에는 같은 형식 쓰지 마.`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function roleOutputHint(role: Role, stage: PromptContext["stage"]): string {
|
||||
// Stage-specific instructions take precedence. The original "decompose
|
||||
// into team" wording only makes sense for plan / implement — for review
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
notifyDiscord,
|
||||
renderStageStart,
|
||||
renderStageEnd,
|
||||
extractDiscordLines,
|
||||
} from "./discord-notify.js";
|
||||
|
||||
// Real LLM call is the default. Set USE_REAL_LLM=false (or the legacy
|
||||
@@ -213,10 +214,17 @@ export async function executeInvocation(
|
||||
// — to make a meaningful judgement, so the cap is generous. Cap is
|
||||
// sized for full HTML/JS/CSS files; LLM context windows are 200k+ so
|
||||
// 64KB stays well inside budget even after 4 stages of accumulation.
|
||||
const aggregated = [managerWork.text, ...childTexts]
|
||||
const aggregatedRaw = [managerWork.text, ...childTexts]
|
||||
.filter(Boolean)
|
||||
.join("\n\n---\n\n")
|
||||
.slice(0, 64_000);
|
||||
.join("\n\n---\n\n");
|
||||
|
||||
// Pull every ```discord-line``` block out before slicing/persisting.
|
||||
// The first extracted line becomes the stage-end Discord message;
|
||||
// the cleaned text (with the blocks stripped) is what flows to the
|
||||
// next stage as priorStages so chat noise doesn't bleed through.
|
||||
const { lines: discordLines, cleaned: aggregatedClean } =
|
||||
extractDiscordLines(aggregatedRaw);
|
||||
const aggregated = aggregatedClean.slice(0, 64_000);
|
||||
|
||||
const result = buildSuccessResult(
|
||||
req.stage,
|
||||
@@ -230,8 +238,13 @@ export async function executeInvocation(
|
||||
// so the message reflects the ACTUAL verdict ("리뷰 통과" vs "결함 발견"
|
||||
// vs "배포 실패"). Previously this was emitted before the verdict was
|
||||
// known, so darang would always say "통과" even when REQUEST_CHANGES.
|
||||
//
|
||||
// If a junior LLM emitted a `discord-line` block, use that verbatim
|
||||
// (it's the LLM speaking in character about its own work). Otherwise
|
||||
// fall back to the hardcoded persona pool.
|
||||
if (req.notifyChannelId) {
|
||||
const verdict = "verdict" in result ? result.verdict : "";
|
||||
const customLine = discordLines[0] ?? "";
|
||||
notifyDiscord({
|
||||
channelId: req.notifyChannelId,
|
||||
message: renderStageEnd({
|
||||
@@ -241,6 +254,7 @@ export async function executeInvocation(
|
||||
verdict,
|
||||
childCount: childTexts.length,
|
||||
filesProduced: ctx.producedFiles.length,
|
||||
...(customLine && { customLine }),
|
||||
}),
|
||||
})
|
||||
.then((r) => {
|
||||
|
||||
Reference in New Issue
Block a user