Skip to content
Open
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
19 changes: 19 additions & 0 deletions docs/company-fit-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# 기업 관점 코멘트 생성 안정화

기업 코멘트는 기본 점수와 독립된 부가 결과다. 생성 실패 시 기본 채점 성공은 유지하지만 companyFit은 null로 남는다. 기존 성공 피드백은 서버 업데이트나 새로고침으로 자동 재생성되지 않는다.

## 실제 재현과 변경

- 기존 Sonnet 4.6 / 1,500토큰 / 20초 설정으로 누락 사례를 재실행하면 20.02초에 company_fit.timeout으로 종료됐다.
- 추가 원인: 프롬프트의 `[f:72]`를 모델이 `fact_id: "f:72"`로 복사했다. 기존 검증은 "72"만 받아 모든 근거를 버렸다. 충분한 시간으로 재실행해도 no_valid_evidence로 실패할 수 있었다.
- 기본 출력 예산을 4,096토큰, 전체 호출 예산을 60초로 조정한다. 설정값으로 덮어쓸 수 있다.
- fact_id는 ID 본문만 제출하도록 설명한다. 서버는 등록된 prompt_id 또는 정확히 `f:` 하나를 붙인 표기만 인식하며, 결과에는 원본 fact_id를 사용한다. 알 수 없는 ID·문항·과도한 길이는 계속 거부한다.
- 출력 잘림은 형식 오류와 마찬가지로 같은 전체 제한 시간 안에서 1회만 재시도한다. 두 번째 요청은 총평 150자, 목록별 1개, 상세 80자의 간결한 형식을 요구한다. API 오류에 대한 애플리케이션 재시도는 추가하지 않는다.

## 데이터 복구

기존 기록을 복구할 때는 최신 SUCCEEDED 피드백이며 COMPLETED 면접, 기업 스냅샷 존재, companyFit 미저장인 행만 대상으로 한다. 기업 스냅샷과 기존 답변만 사용해 코멘트를 다시 생성한다. 원문 답변과 모델 출력은 비공개 백업에만 보관한다.

수정 결과를 저장하기 전에 백업과 검증을 완료한다. company_fit 컬럼만 조건부 갱신하며 이미 존재하는 코멘트는 덮어쓰지 않는다. 기본 점수·산출 근거·문항별 평가·답변·질문·면접 상태는 변경하지 않는다. 운영 복구는 원격 코드의 자동 DB 수정 동작이 아니며 별도 관리 작업으로 수행한다.

실제 모델 재실행으로 누락 사례 4건의 유효 코멘트를 확인했다. 설명 기회가 없는 답변은 빈 근거 목록과 관찰 한계 코멘트를 허용하며, 근거 없는 긍정·부정 평가를 만들지 않는다.
13 changes: 8 additions & 5 deletions src/app/core/common/company/fit/evaluate.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def __init__(
max_tokens: int,
temperature: float,
answer_max_chars: int,
timeout_seconds: float = 20,
timeout_seconds: float = 60,
) -> None:
if not math.isfinite(timeout_seconds) or timeout_seconds <= 0:
raise ValueError("company fit timeout must be positive and finite")
Expand Down Expand Up @@ -99,13 +99,16 @@ async def execute(
"elapsed_ms": (time.monotonic() - started) * 1000,
},
)
if result is not None or response.stop_reason == "max_tokens":
if result is not None:
return result
if attempt == 1:
logger.warning("company_fit.retry reason=invalid_submission attempt=1")
reason = "output_truncated" if response.stop_reason == "max_tokens" else "invalid_submission"
Comment on lines +102 to +105

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document retries after token truncation

For responses ending with stop_reason=max_tokens, this now falls through to a second model call, but docs/api.md:1017-1018 still says that only invalid submissions are regenerated and token truncation is never retried. Consumers using that reference to estimate call volume, cost, or timeout behavior will observe different semantics, so document the new single compact retry.

Useful? React with 👍 / 👎.

logger.warning("company_fit.retry reason=%s attempt=1", reason)
message += (
"\n직전 응답이 제출 형식을 충족하지 못했습니다. "
"지정된 도구로 필수 필드와 길이를 지키고 제공된 fact_id·question_id만 사용해 제출하세요."
"지정된 도구로 필수 필드와 길이를 지키고 제공된 fact_id·question_id만 사용해 제출하세요. "
"총평은 150자 이내, matched_focus와 gaps는 각각 최대 1개, "
"각 설명은 80자 이내로 간결하게 제출하세요."
)
except TimeoutError:
logger.warning("company_fit.timeout elapsed_ms=%.0f", (time.monotonic() - started) * 1000)
Expand Down Expand Up @@ -181,7 +184,7 @@ def _clean_items(
continue
if not all(isinstance(value, str) for value in ids):
continue
fact = next((fact for fact in company.facts if fact.prompt_id == fact_id), None)
fact = next((fact for fact in company.facts if fact_id in (fact.prompt_id, f"f:{fact.prompt_id}")), None)
if fact is None or fact.fact_id in seen:
continue
valid_ids = list(dict.fromkeys(value for value in ids if value in question_ids))
Expand Down
3 changes: 2 additions & 1 deletion src/app/core/common/company/fit/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
- 관찰 기회가 없으면 그 가치의 긍정·부정을 만들지 마라. 두 목록이 모두 빈 배열이어도 된다.
이때 총평은 관찰 한계를 중립적으로 설명한다. 미답변 문항은 근거로 사용하지 마라.
- 모든 신호는 연습용 관점이다. 출처가 직접 말한 신호와 추론 신호를 구분했다고 주장하지 마라.
- fact_id는 [기업 정보]의 [f:…] 값만 쓴다. question_ids는 아래 문항의 question_id만 쓴다.
- fact_id는 [기업 정보]의 [f:…] 안에서 f: 뒤의 ID만 쓴다. 예: [f:72]이면 "72"이며 "f:72"가 아니다.
- question_ids는 아래 문항의 question_id만 쓴다.
- 문장에서 문항을 가리킬 때는 블록 번호(Q1, Q2 …)를 쓴다.
question_id 값은 도구의 question_ids 필드에만 쓰고 문장에 쓰지 않는다.
- 점수, 등급, 합격 가능성, "이 회사에 맞다/맞지 않다" 같은 판정을 쓰지 마라.
Expand Down
4 changes: 2 additions & 2 deletions src/app/main/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,8 @@ class CompanyFitSettings(BaseSettings):

# None이면 기존 피드백과 같은 ANTHROPIC_TEXT_MODEL 사용.
MODEL: str | None = None
MAX_TOKENS: int = Field(1500, gt=0)
TIMEOUT_SECONDS: float = Field(20, gt=0, allow_inf_nan=False)
MAX_TOKENS: int = Field(4096, gt=0)
TIMEOUT_SECONDS: float = Field(60, gt=0, allow_inf_nan=False)
Comment on lines +178 to +179

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the published company-fit budget defaults

When operators rely on the API reference identified by README.md as the sole configuration reference, docs/api.md:410 and docs/api.md:918 still advertise a 20-second timeout and 1,500-token limit. The runtime now defaults to 60 seconds and 4,096 tokens, so deployment capacity, latency, and cost estimates based on the documented values will be incorrect; update those documented defaults with this change.

Useful? React with 👍 / 👎.

TEMPERATURE: float = Field(0.2, ge=0, le=1)


Expand Down
49 changes: 42 additions & 7 deletions tests/core/common/company/test_fit_recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,19 +45,14 @@ async def call(**kwargs: Any) -> AnthropicCallResult:
assert "직전 응답" in calls[1]["messages"][0]["content"]


@pytest.mark.parametrize("failure", ["truncated", "api"])
async def test_truncated_or_api_error_does_not_trigger_application_retry(
failure: str, monkeypatch: pytest.MonkeyPatch
) -> None:
async def test_api_error_does_not_trigger_application_retry(monkeypatch: pytest.MonkeyPatch) -> None:
client = RecordingCompanyClient()
calls = 0

async def call(**kwargs: Any) -> AnthropicCallResult:
nonlocal calls
calls += 1
if failure == "api":
raise AnthropicTextClientError("비공개 오류", kind="transient")
return response(fit_output(), "max_tokens")
raise AnthropicTextClientError("비공개 오류", kind="transient")

monkeypatch.setattr(client, "call", call)
assert (
Expand Down Expand Up @@ -128,3 +123,43 @@ async def run(job_id: str) -> None:
assert [getattr(record, "job_id", None) for record in records] == ["job-a", "job-b", None]
assert "job_id=job-a" in records[0].getMessage()
assert "비공개 답변" not in caplog.text


@pytest.mark.parametrize("second_stop", ["tool_use", "max_tokens"])
async def test_truncated_submission_gets_one_compact_retry(second_stop: str, monkeypatch: pytest.MonkeyPatch) -> None:
client = RecordingCompanyClient()
calls: list[dict[str, Any]] = []

async def call(**kwargs: Any) -> AnthropicCallResult:
calls.append(kwargs)
return response(fit_output(), "max_tokens" if len(calls) == 1 else second_stop)

monkeypatch.setattr(client, "call", call)
result = await CompanyFitEvaluation(client, "stub", 4096, 0.2, 3000).execute((target(),), company_context(), None)
assert (result is not None) == (second_stop == "tool_use")
assert len(calls) == 2
assert "각각 최대 1개" in calls[1]["messages"][0]["content"]
assert all(call["max_tokens"] == 4096 for call in calls)


@pytest.mark.parametrize("known", [True, False])
async def test_display_prefixed_fact_ids_require_a_registered_fact(
known: bool, monkeypatch: pytest.MonkeyPatch
) -> None:
client = RecordingCompanyClient()
raw = fit_output()
for key in ("matched_focus", "gaps"):
for item in raw[key]:
item["fact_id"] = "f:" + (item["fact_id"] if known else "unknown")

async def call(**kwargs: Any) -> AnthropicCallResult:
return response(raw)

monkeypatch.setattr(client, "call", call)
result = await CompanyFitEvaluation(client, "stub", 4096, 0.2, 3000).execute((target(),), company_context(), None)
if known:
assert result is not None
assert result.matched_focus[0].fact_id == "t1"
assert result.gaps[0].fact_id == "p1"
else:
assert result is None
2 changes: 1 addition & 1 deletion tests/inbound/test_company_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ def test_invalid_company_fit_environment_is_rejected(name: str, value: str, monk
@pytest.mark.parametrize("temperature", [0, 1])
def test_company_fit_setting_boundaries_are_allowed(temperature: float) -> None:
settings = CompanyFitSettings(MAX_TOKENS=1, TEMPERATURE=temperature)
assert settings.TIMEOUT_SECONDS == 20
assert settings.TIMEOUT_SECONDS == 60
assert settings.MAX_TOKENS == 1
assert temperature == settings.TEMPERATURE

Expand Down
Loading