Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4d9f23c
fix(tools): remove lexical pseudo-topic models
seonghobae Aug 9, 2026
6f58d3d
fix(docs): restore complete changelog
seonghobae Aug 9, 2026
3f1803b
docs: start canonical architecture decision index
seonghobae Aug 9, 2026
77ff173
docs: record topic measurement authority ADR
seonghobae Aug 9, 2026
26bbceb
docs: link topic measurement authority decision
seonghobae Aug 9, 2026
0e1fc6f
docs: scope topic measurement ADR to Naruon
seonghobae Aug 9, 2026
a1a4676
test(tools): tighten topic boundary evidence
seonghobae Aug 9, 2026
3906b0e
docs: complete topic intelligence decision package
seonghobae Aug 9, 2026
cb8a752
fix(docs): tighten topic contract evidence
seonghobae Aug 9, 2026
6e3f613
Merge develop into fix/remove-lexical-topic-heuristics
seonghobae Aug 9, 2026
59bfd5a
Merge develop into fix/remove-lexical-topic-heuristics
seonghobae Aug 9, 2026
4fe5c9a
merge: sync with develop
seonghobae Aug 13, 2026
682c32d
ci: add one-shot conflict-safe sync for PR 1297
seonghobae Aug 14, 2026
c906692
Merge develop into fix/remove-lexical-topic-heuristics
github-actions[bot] Aug 14, 2026
0b0f6ac
chore: retrigger exact-head checks after develop sync
seonghobae Aug 14, 2026
01fbdac
ci: add one-shot AGENTS guidance repair
seonghobae Aug 14, 2026
b99558d
ci: fix one-shot AGENTS repair trigger
seonghobae Aug 14, 2026
61e9de0
ci: simplify one-shot AGENTS repair
seonghobae Aug 14, 2026
6c5dbaa
ci: repair one-shot workflow YAML
seonghobae Aug 14, 2026
7f2d34a
docs: clarify scoped identity dimensions
github-actions[bot] Aug 14, 2026
e8c8f43
chore: retrigger exact-head gates after scoped identity repair
seonghobae Aug 14, 2026
13a6e66
Merge protected develop into topic boundary branch
seonghobae Aug 14, 2026
d417bcd
merge(develop): synchronize topic-intelligence candidate
seonghobae Aug 15, 2026
fd8a71f
Merge branch 'develop' into fix/remove-lexical-topic-heuristics
opencode-agent[bot] Aug 15, 2026
c8a785f
merge(develop): integrate current protected base
seonghobae Aug 15, 2026
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
20 changes: 20 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,17 @@ in this repo.
knowledge-graph pipeline (DOM decomposition, entity/relation extraction,
grounded graph retrieval) should ground itself in the relevant layout-analysis
and knowledge-graph / grounded-retrieval literature.

### Structural topic-model boundary

- Do not implement or describe hard-coded term lists, term frequency,
embeddings, or LLM-assigned labels as structural topic modeling (STM).
Fixed business labels are not topic-posterior estimates, and the explicitly
lexical `keyword_extractor` must not be used as topic evidence.
- Topic inference requires a versioned fitted TEPP model and its frozen
preprocessing and vocabulary contract. If that fitted model is unavailable,
fail closed; do not return a default label, template agenda, or substitute
keyword/embedding/LLM result presented as STM.
<!-- END cwl-agent-guidance -->

## Release governance defaults
Expand Down Expand Up @@ -425,6 +436,15 @@ in this repo.
- Public audit/event identifiers that may use human-readable prefixes must not
be stored in artificially short `varchar(n)` columns; use opaque source UIDs
that fit seeded smoke data and provider evidence without truncation.
- Conceptual ERDs, API schemas, persistence models, and fixtures must not mark a
reusable business identifier such as `document_ref`, `model_id`, `topic_id`,
or `label_id` as an unscoped primary or foreign key. Use an opaque immutable
reference that binds the full scope or an explicit composite identity with the
applicable snapshot revision, model version, request/result scope, or label
version. Define the required identity tuple for each entity; require only the
dimensions relevant to that entity. Never join snapshots, model artifacts,
topic components, or label evidence by a bare document, model, topic, rank,
label, or display value.
- When reviews find public/private identifier leaks, stale API fixture shapes, or recurring bug patterns, update tests, frontend mocks, E2E mocks, README examples, architecture docs, and explicitly record the anti-pattern in `AGENTS.md` so the same bug pattern does not reappear in copied examples.
- When reviews find missing browser security headers or tabnabbing hardening,
update both backend header tests and frontend link tests. Global backend
Expand Down
15 changes: 15 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,21 @@ Runtime database connectivity is secret-injected: `backend/core/config.py` has
no fallback `DATABASE_URL`, so missing database configuration fails at startup
rather than silently using shared development credentials.

## Topic-intelligence boundary

Naruon has no live Structural Topic Modeling endpoint, fitted topic artifact,
or topic-result persistence. The retained `keyword_extractor` is deterministic
lexical metadata and must not feed topic, agenda, search, or norm-group
inference. A future adapter may consume a separately accepted, versioned TEPP
artifact/API only when frozen preprocessing and vocabulary, covariate design,
mixed-membership posterior uncertainty, diagnostics, provenance, and explicit
abstention are all available. Missing or incompatible scientific authority
fails closed. Naruon owns authentication, authorization, request validation,
the adapter envelope, and disclosure policy; TEPP would own the scientific
payload. See the canonical documentation graph in
[`docs/topic-intelligence/README.md`](docs/topic-intelligence/README.md) and
[`ADR-0001`](docs/adr/0001-topic-measurement-authority.md).

## Workspace navigation boundary

The Next.js shell opens the Today execution dashboard for first-run sessions and
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
## [Unreleased]
- EmailDetail 테스트가 지원하지 않는 스레드 병합/분리 버튼을 `textContent`뿐 아니라 `aria-label`과 `title` 접근 가능 이름으로도 검출하도록 바꿔, 아이콘 전용 버튼 회귀를 놓치지 않습니다.
### 주제 측정 경계 (Topic Measurement)

- STM 결과로 오인될 수 있었던 하드코딩 용어표 기반
`email_categorizer`와 `meeting_agenda_generator`를 도구 레지스트리에서
제거했습니다. `keyword_extractor`는 결정론적 단어 빈도 유틸리티로 유지하되
주제 posterior 근거로 사용하지 않는 경계를 문서화했습니다. 현재 Naruon에는
fitted TEPP 모델 기반 production 주제 측정 API가 없으므로, 모델 부재 시
기본 라벨이나 템플릿으로 대체하지 않고 fail closed 합니다.
- 이 경계의 PRD, TRD, ADR, Architecture, API 계약, JSON Schema, UML,
개념 ERD, 보안·위협 모델, 테스트·운영 전략, 추적성 및 문서 적합성 평가를
`docs/topic-intelligence/`에 하나의 상태 표시 문서 그래프로 정리했습니다.
이는 미래 계약의 설계 근거이며, 현재 runtime 구현이나 물리 DB 엔터티가
존재한다는 주장이 아닙니다.
- UUID V4 제너레이터(`uuid_v4_generator`) 도구를 추가하여 런타임에서 범용 고유 식별자 버전 4를 랜덤으로 생성할 수 있게 하였습니다. 테스트 커버리지 100%를 보장합니다.

### 보안 패치 (CodeQL extended current-head)

- `cryptography`를 `50.0.0`으로 갱신해 공격자 제공 PKCS#7 EnvelopedData 복호화 결과의 오류·타이밍 차이로 발생하는 Bleichenbacher oracle(`CVE-2026-69247`, `GHSA-g6cj-pr64-35w5`)을 제거하고, backend·uv lock·hash lock·Strix CI 의존성 증거를 같은 버전으로 동기화했습니다. Strix 잠금은 `google-cloud-aiplatform==1.160.0`의 `<7` 제약을 위반하던 `protobuf==7.35.1`을 이미 검증된 `6.33.6`으로 복구해 다시 해석·설치 가능하게 했습니다.
Expand Down
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,13 @@ Next.js frontend ──> FastAPI backend (control plane) ──> Postgres + pgve
test/lint/build), plus `bandit`, `codeql`, `trivy`, `scorecard`,
`pr-governance`, `docker-publish` (GHCR on `v*` tags matching `VERSION`), and
`mail-smoke`. Actions are pinned to full commit SHAs.
- Topic intelligence is **not implemented**. Never use lexical frequencies,
embeddings, zero-shot labels, or request-time LLM labels as an STM result.
The retained `keyword_extractor` is lexical metadata only. Any future adapter
is blocked on a versioned fitted TEPP artifact/API with frozen preprocessing,
mixed-membership uncertainty and diagnostics; absence or incompatibility
fails closed. Start at `docs/topic-intelligence/README.md` and
`docs/adr/0001-topic-measurement-authority.md`.

## Key conventions

Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ mail/calendar/file systems.
## Quick Links
- [Installation & Setup](#five-minute-local-path)
- [Architecture](docs/architecture/)
- [Topic-intelligence documentation set](docs/topic-intelligence/README.md)
- [Architecture decisions](docs/adr/README.md)
- [Contributing](CONTRIBUTING.md)
- [Code of Conduct](CODE_OF_CONDUCT.md)
- [Security Policy](SECURITY.md)
Expand Down Expand Up @@ -48,6 +50,12 @@ mail/calendar/file systems.
auto-merge, and mechanical merge actions run as the target repository's
`github-actions[bot]` through the central workflow. Pending CodeRabbit or
required-check evidence is a wait state, not a hard blocker.
- Topic intelligence is not currently a live Naruon capability. The lexical
`keyword_extractor` is metadata only; Naruon fails closed rather than present
keyword, embedding, or LLM labels as Structural Topic Modeling. The product,
technical, architecture, contract, security, UML, conceptual ERD, test, and
operability records are indexed in
[`docs/topic-intelligence/`](docs/topic-intelligence/README.md).
- Security governance is source-backed through signed
`/api/security/access-surface`. The endpoint reads scoped WebDAV, CalDAV, and
connector evidence plus durable `security_audit_events`, reuses the deny-first
Expand Down
93 changes: 3 additions & 90 deletions backend/api/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -706,26 +706,6 @@ async def base64_decoder_handler(params: Dict[str, Any]) -> Dict[str, str]:
"합니다",
}
)
_CATEGORY_TERMS = (
("Urgent", ("urgent", "asap", "immediate", "긴급", "시급", "빨리")),
("Finance", ("invoice", "billing", "payment", "결제", "청구", "송금")),
("Scheduling", ("meeting", "schedule", "appointment", "회의", "일정", "약속")),
)
_AGENDA_TOPICS = (
("Project Status Update", ("project", "프로젝트", "과제")),
("Discuss Pending Issues", ("issue", "bug", "blocker", "문제", "오류", "장애")),
("Decisions Required", ("decision", "approve", "결정", "승인")),
(
"Timeline and Milestones",
("deadline", "milestone", "timeline", "마감", "기한", "일정"),
),
(
"Budget and Resource Review",
("budget", "cost", "resource", "예산", "비용", "자원"),
),
)


def _normalize_analysis_text(value: str) -> str:
"""Normalize user text for deterministic, multilingual rule matching."""
if len(value) > ANALYSIS_TEXT_MAX_CHARS:
Expand All @@ -740,44 +720,8 @@ def _analysis_tokens(value: str) -> list[str]:
return _ANALYSIS_TOKEN_PATTERN.findall(_normalize_analysis_text(value))


def _contains_analysis_term(normalized_text: str, term: str) -> bool:
"""Match ASCII terms on word boundaries and Korean terms as morpheme stems."""
normalized_term = _normalize_analysis_text(term)
if normalized_term.isascii():
pattern = rf"(?<![a-z0-9]){re.escape(normalized_term)}(?![a-z0-9])"
return re.search(pattern, normalized_text) is not None
return normalized_term in normalized_text


async def email_categorizer_handler(params: Dict[str, Any]) -> Any:
"""Categorize email text with deterministic Korean and English rules."""
content = _normalize_analysis_text(params.get("email_content", ""))
categories = [
category
for category, terms in _CATEGORY_TERMS
if any(_contains_analysis_term(content, term) for term in terms)
]

if not categories:
categories = ["General"]

return {"categories": categories, "primary_category": categories[0]}


registry.register(
ToolInfo(
code="email_categorizer",
name="이메일 자동 분류기 (Email Categorizer)",
description="이메일 내용을 분석하여 알맞은 카테고리로 자동 분류합니다.",
category="이메일 분석",
parameters={"email_content": "string"},
),
email_categorizer_handler,
)


async def keyword_extractor_handler(params: Dict[str, Any]) -> Any:
"""Extract stable keywords ranked by frequency and first occurrence."""
"""Extract deterministic lexical terms by frequency and first occurrence."""
candidates = [
token
for token in _analysis_tokens(params.get("text", ""))
Expand All @@ -801,46 +745,14 @@ async def keyword_extractor_handler(params: Dict[str, Any]) -> Any:
ToolInfo(
code="keyword_extractor",
name="주요 키워드 추출기 (Keyword Extractor)",
description="텍스트 본문에서 가장 중요한 키워드를 추출합니다.",
description="텍스트 본문에서 빈도와 최초 출현 순으로 반복 용어를 추출합니다.",
category="이메일 분석",
parameters={"text": "string"},
),
keyword_extractor_handler,
)


async def meeting_agenda_generator_handler(params: Dict[str, Any]) -> Any:
"""Generate a deterministic agenda from Korean or English discussion topics."""
context = _normalize_analysis_text(params.get("discussion_context", ""))
if len(_analysis_tokens(context)) < 2:
return {
"agenda_items": ["Introductions", "Open Discussion"],
"estimated_duration_minutes": 30,
}

items = ["Review previous action items"]
items.extend(
agenda_item
for agenda_item, terms in _AGENDA_TOPICS
if any(_contains_analysis_term(context, term) for term in terms)
)
items.append("Next Steps and Action Items")

return {"agenda_items": items, "estimated_duration_minutes": len(items) * 15}


registry.register(
ToolInfo(
code="meeting_agenda_generator",
name="회의 아젠다 생성기 (Meeting Agenda Generator)",
description="논의 컨텍스트를 바탕으로 적절한 회의 아젠다를 자동으로 생성합니다.",
category="일정 관리",
parameters={"discussion_context": "string"},
),
meeting_agenda_generator_handler,
)


async def uuid_v4_generator_handler(params: Dict[str, Any]) -> Dict[str, str]:
return {"uuid": str(uuid.uuid4())}

Expand All @@ -857,6 +769,7 @@ async def uuid_v4_generator_handler(params: Dict[str, Any]) -> Dict[str, str]:
)



@router.get("/tools", response_model=list[ToolInfo])
def get_tools() -> list[ToolInfo]:
"""
Expand Down
96 changes: 15 additions & 81 deletions backend/tests/test_tools_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,21 @@ def test_get_tool_not_found():
assert response.json() == {"detail": "Tool not found"}


@pytest.mark.parametrize(
"tool_code", ["email_categorizer", "meeting_agenda_generator"]
)
def test_registry_omits_lexical_pseudo_topic_tools(tool_code):
assert registry.get(tool_code) is None


def test_keyword_extractor_is_disclosed_as_lexical_term_frequency():
tool = registry.get("keyword_extractor")
assert tool is not None
assert tool.description == (
"텍스트 본문에서 빈도와 최초 출현 순으로 반복 용어를 추출합니다."
)

Comment thread
coderabbitai[bot] marked this conversation as resolved.

@pytest.mark.asyncio
async def test_execute_tool_success():
with TestClient(app) as client:
Expand Down Expand Up @@ -1155,52 +1170,6 @@ def test_detect_text_language_ko():
assert _detect_text_language("안녕하세요") == "ko"


@pytest.mark.asyncio
async def test_email_categorizer_handler():
from api.tools import email_categorizer_handler

# Test Finance category
result = await email_categorizer_handler(
{"email_content": "Please pay this invoice soon."}
)
assert "Finance" in result["categories"]

# Test Scheduling category
result = await email_categorizer_handler(
{"email_content": "Let's schedule a meeting."}
)
assert "Scheduling" in result["categories"]

# Test Urgent category
result = await email_categorizer_handler({"email_content": "This is urgent!"})
assert "Urgent" in result["categories"]

# Test General category (fallback)
result = await email_categorizer_handler({"email_content": "Hello, how are you?"})
assert "General" in result["categories"]

# Test multiple categories
result = await email_categorizer_handler(
{"email_content": "URGENT: Meeting to discuss invoice payment"}
)
assert result == {
"categories": ["Urgent", "Finance", "Scheduling"],
"primary_category": "Urgent",
}

# ASCII category rules use token boundaries instead of substring matching.
result = await email_categorizer_handler(
{"email_content": "The prepayment plan is documented."}
)
assert result["categories"] == ["General"]

# Unicode compatibility forms and Korean stems remain matchable.
result = await email_categorizer_handler(
{"email_content": "긴급 회의에서 청구 금액을 검토합니다."}
)
assert result["categories"] == ["Urgent", "Finance", "Scheduling"]


@pytest.mark.asyncio
async def test_keyword_extractor_handler():
from api.tools import keyword_extractor_handler
Expand All @@ -1224,41 +1193,6 @@ async def test_keyword_extractor_handler():
assert empty == {"keywords": [], "keyword_count": 0}


@pytest.mark.asyncio
async def test_meeting_agenda_generator_handler():
from api.tools import meeting_agenda_generator_handler

# Test with short context
result = await meeting_agenda_generator_handler({"discussion_context": "short"})
assert result["agenda_items"] == ["Introductions", "Open Discussion"]
assert result["estimated_duration_minutes"] == 30

# Test with project and issue context
result = await meeting_agenda_generator_handler(
{"discussion_context": "The project has an issue that needs fixing."}
)
assert "Review previous action items" in result["agenda_items"]
assert "Project Status Update" in result["agenda_items"]
assert "Discuss Pending Issues" in result["agenda_items"]
assert "Next Steps and Action Items" in result["agenda_items"]
assert result["estimated_duration_minutes"] == len(result["agenda_items"]) * 15

# Korean context covers decision, timeline, and resource agenda paths.
result = await meeting_agenda_generator_handler(
{"discussion_context": "프로젝트 예산 승인과 마감 일정 문제를 결정합니다."}
)
assert result["agenda_items"] == [
"Review previous action items",
"Project Status Update",
"Discuss Pending Issues",
"Decisions Required",
"Timeline and Milestones",
"Budget and Resource Review",
"Next Steps and Action Items",
]
assert result["estimated_duration_minutes"] == 105


def test_execute_analysis_tool_rejects_oversized_text():
from api.tools import ANALYSIS_TEXT_MAX_CHARS

Expand Down
Loading
Loading