Entity & Term Extraction
Implementation details for AD-015.
LLM Extraction Schema
The AIEntityExtractTool uses ChatStructured() with this JSON schema:
{
"name": "extraction_result",
"description": "Named entities and terminology candidates extracted from localization content",
"strict": true,
"schema": {
"type": "object",
"properties": {
"blocks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"block_id": { "type": "string" },
"entities": {
"type": "array",
"items": {
"type": "object",
"properties": {
"text": { "type": "string" },
"type": {
"type": "string",
"enum": [
"person",
"organization",
"product",
"location",
"date",
"time",
"currency",
"measurement",
"other"
]
},
"dnt": { "type": "boolean" },
"offset": { "type": "integer" },
"length": { "type": "integer" },
"confidence": { "type": "number" }
},
"required": ["text", "type", "dnt", "offset", "length", "confidence"]
}
},
"term_candidates": {
"type": "array",
"items": {
"type": "object",
"properties": {
"text": { "type": "string" },
"definition": { "type": "string" },
"category": {
"type": "string",
"enum": ["brand", "technical", "ui", "legal", "marketing", "general"]
},
"translatability": { "type": "string", "enum": ["dnt", "consistent", "free"] },
"confidence": { "type": "number" },
"offset": { "type": "integer" },
"length": { "type": "integer" }
},
"required": [
"text",
"definition",
"category",
"translatability",
"confidence",
"offset",
"length"
]
}
}
},
"required": ["block_id", "entities", "term_candidates"]
}
}
},
"required": ["blocks"]
}
}
System Prompt
You are a localization specialist analyzing source content for a translation project.
Given text blocks, identify:
1. Named entities: people, organizations, products, locations, dates, times, currencies,
measurements. For each, indicate whether it should be marked do-not-translate (DNT).
- Person names: usually DNT unless the project localizes names
- Brand/product names: usually DNT
- Dates/times/currencies/measurements: usually NOT DNT (they need locale-specific formatting)
- Locations: context-dependent
2. Terminology candidates: domain-specific terms that should be translated consistently
across the project. These are words/phrases that carry specific meaning in this context
and would benefit from a terms entry. Exclude common words.
- "dnt" = never translate (brand names, acronyms that stay in source language)
- "consistent" = translate, but the same way everywhere
- "free" = translate naturally, no consistency requirement
Report character offsets relative to each block's text. Only report genuinely useful
entities and terms — quality over quantity.
Batch Prompt Format
Analyze these {n} text blocks from a {source_locale} localization project:
Block (id: {block_id}):
"{block_text}"
Block (id: {block_id}):
"{block_text}"
...
Existing terms (do not re-propose): {known_terms}
NER Provider Implementations
Azure Language Services
POST {endpoint}/language/:analyze-text?api-version=2024-11-01
{
"kind": "EntityRecognition",
"analysisInput": {
"documents": [
{ "id": "1", "language": "en", "text": "..." },
{ "id": "2", "language": "en", "text": "..." }
]
}
}
Azure entity type mapping to model.EntityType:
| Azure Type | model.EntityType |
|---|---|
| Person, PersonType | EntityPerson |
| Organization, OrganizationMedical, OrganizationSports, OrganizationStockExchange | EntityOrganization |
| Product, ComputingProduct | EntityProduct |
| Address, Airport, City, Continent, CountryRegion, GPE, Geological, Location, State, Structural | EntityLocation |
| Date, DateTime, DateRange, DateTimeRange | EntityDate |
| Time, TimeRange | EntityTime |
| Currency | EntityCurrency |
| Age, Area, Dimension, Height, Length, Number, NumberRange, Ordinal, Percentage, Speed, Temperature, Volume, Weight | EntityMeasurement |
| (all others) | EntityOther |
Batch: up to 25 documents per request, 5120 characters each.
spaCy (via Plugin Bridge)
Uses the Java/Python plugin bridge (Framework AD-007). spaCy NER models output:
| spaCy Label | model.EntityType |
|---|---|
| PERSON | EntityPerson |
| ORG, NORP | EntityOrganization |
| PRODUCT, WORK_OF_ART | EntityProduct |
| GPE, LOC, FAC | EntityLocation |
| DATE | EntityDate |
| TIME | EntityTime |
| MONEY | EntityCurrency |
| QUANTITY, PERCENT, CARDINAL, ORDINAL | EntityMeasurement |
Review Queue SQLite Schema
CREATE TABLE review_items (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
type TEXT NOT NULL, -- 'term_candidate', 'entity_review'
status TEXT NOT NULL DEFAULT 'pending', -- 'pending', 'assigned', 'approved', 'rejected'
push_id TEXT NOT NULL DEFAULT '',
data TEXT NOT NULL, -- JSON: TermCandidateAnnotation or EntityAnnotation
occurrences TEXT NOT NULL DEFAULT '[]',
assigned_to TEXT NOT NULL DEFAULT '',
decided_by TEXT NOT NULL DEFAULT '',
decided_at TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
edits TEXT NOT NULL DEFAULT '{}',
confidence REAL NOT NULL DEFAULT 0,
locale TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
CREATE INDEX idx_review_items_project_status ON review_items(project_id, status);
CREATE INDEX idx_review_items_project_type ON review_items(project_id, type);
CREATE INDEX idx_review_items_assigned ON review_items(project_id, assigned_to);
CREATE INDEX idx_review_items_confidence ON review_items(project_id, confidence);
-- Track rejected terms to avoid re-proposing
CREATE TABLE rejected_terms (
project_id TEXT NOT NULL,
term_text TEXT NOT NULL,
locale TEXT NOT NULL,
rejected_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (project_id, term_text, locale)
);
-- DNT list (auto-approved entities + user-confirmed DNT terms)
CREATE TABLE dnt_entries (
project_id TEXT NOT NULL,
text TEXT NOT NULL,
entity_type TEXT NOT NULL DEFAULT '',
locale TEXT NOT NULL,
source TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (project_id, text, locale)
);
Extraction Worker Flow
EventPushCompleted
│
▼
AutomationEngine matches rule "auto-extract-entities"
│
▼
ActionExecutor dispatches flow "entity-term-extract"
│
▼
Worker picks up job
│
├─ 1. Load changed blocks from push (by push_id)
├─ 2. Load existing terms (for dedup) + rejected terms (for skip list)
│
├─ 3. NER pass (if configured)
│ ├─ Batch blocks into NER requests (25 per batch, Azure limit)
│ ├─ Map NER entities → EntityAnnotation
│ ├─ Auto-approve configured types → write directly to block annotations
│ └─ Queue remaining entities as ReviewItems
│
├─ 4. LLM pass
│ ├─ Batch blocks (configurable, default 10 per call)
│ ├─ Concurrent calls (configurable, default 4)
│ ├─ Exclude blocks fully covered by NER (optimization)
│ ├─ Parse structured response → TermCandidateAnnotation + EntityAnnotation
│ ├─ Dedup: skip terms already in the terms store or rejected list
│ └─ Apply confidence threshold: high → normal queue, low → low-confidence queue
│
├─ 5. Merge
│ ├─ Reconcile NER + LLM entities (prefer LLM classification for overlaps)
│ ├─ Group identical term text → single ReviewItem with aggregated occurrences
│ └─ Attach all annotations to blocks
│
└─ 6. Persist
├─ Write ReviewItems to review_items table
├─ Write auto-approved entities to block annotations
├─ Write auto-approved DNT entries to dnt_entries table
└─ Emit EventExtractionCompleted (for downstream automation)
bowrain-app API Contract
Authentication
POST /api/v1/auth/device-code → { device_code, user_code, verification_uri }
POST /api/v1/auth/token → { access_token, refresh_token }
POST /api/v1/auth/refresh → { access_token, refresh_token }
Workspace & Project
GET /api/v1/workspaces → [{ id, slug, name }]
GET /api/v1/workspaces/:slug/projects → [{ id, name, source_locale, target_locales }]
Review Queue
GET /api/v1/projects/:id/review-queue
?type=term_candidate|entity_review
&status=pending|assigned
&confidence=high|low
&assigned_to=me|unassigned
&limit=50
&cursor=...
→ { items: [ReviewItem], next_cursor, total, remaining }
POST /api/v1/projects/:id/review-queue/:item_id/decide
{ "decision": "approve"|"reject", "comment": "...", "edits": { "definition": "...", "category": "..." } }
→ { ok: true, concept_id?: "..." } // concept_id returned on term approval
POST /api/v1/projects/:id/review-queue/batch-decide
{ "item_ids": [...], "decision": "approve"|"reject" }
→ { ok: true, decided: 5 }
POST /api/v1/projects/:id/review-queue/:item_id/assign
{ "user_id": "..." }
POST /api/v1/projects/:id/review-queue/:item_id/split
{ "occurrence_ids": ["..."] }
→ { original: ReviewItem, new_item: ReviewItem }
Sync (offline-first)
POST /api/v1/projects/:id/review-queue/sync
{ "decisions": [{ "item_id": "...", "decision": "approve", "edits": {...}, "decided_at": "..." }] }
→ { synced: 5, conflicts: [] }
Notification Schema
CREATE TABLE notifications (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'general', -- 'review.assigned', 'review.completed',
-- 'extraction.completed', 'general'
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
project_id TEXT NOT NULL DEFAULT '',
link_url TEXT NOT NULL DEFAULT '', -- deep link target
read INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_notifications_user ON notifications(user_id, read, created_at DESC);
Delivery layers (incremental):
- Polling (v1):
GET /api/v1/notifications?unread=true&limit=20— simple, works everywhere - WebSocket (v2): Extend existing
ws_collab.gowith a notification channel per user — real-time bell badge updates - Push (v3): APNs/FCM for bowrain-app background notifications — device token registration via
POST /api/v1/notifications/devices
Notification API:
GET /api/v1/notifications?unread=true&limit=20&cursor=...
POST /api/v1/notifications/:id/read
POST /api/v1/notifications/read-all
DELETE /api/v1/notifications/:id
Editor Entity Integration
BlockInfoResponse Changes
// In bowrain/server/editor.go
type BlockInfoResponse struct {
// ... existing fields ...
Entities []EntityInfoResponse `json:"entities,omitempty"`
}
type EntityInfoResponse struct {
Key string `json:"key"` // annotation key (e.g. "entity:0")
Text string `json:"text"`
Type string `json:"type"` // "person", "organization", "product", etc.
Start int `json:"start"` // character offset in source
End int `json:"end"`
DNT bool `json:"dnt"`
Source string `json:"source,omitempty"` // "llm", "ner", "manual"
Locale string `json:"locale,omitempty"`
}
Entity Mutation Endpoints
POST /api/v1/projects/:id/blocks/:block_id/entities
{ "text": "...", "type": "person", "dnt": true, "start": 5, "end": 15 }
PUT /api/v1/projects/:id/blocks/:block_id/entities/:idx
{ "type": "organization", "dnt": false }
DELETE /api/v1/projects/:id/blocks/:block_id/entities/:idx
POST /api/v1/projects/:id/blocks/:block_id/entities/:idx/promote
→ creates TermCandidateAnnotation from entity, routes to review queue
Editor Component Hierarchy
VisualEditorLayout
├── FormattedSourceDisplay
│ └── EntityHighlight (inline, colored background per type)
│ └── EntityPopover (click: type picker, DNT toggle, promote-to-term)
├── ContextPanel (right sidebar)
│ ├── TerminologySection (existing)
│ └── EntitiesSection (NEW)
│ └── EntityListItem (type badge, text, DNT lock, source indicator)
└── EditorToolbar
└── MarkEntityButton (select text → Cmd+E → type picker)
Entity Color Tokens (CSS custom properties)
--entity-person: hsl(210 80% 92%); /* blue tint */
--entity-organization: hsl(270 70% 92%); /* purple tint */
--entity-product: hsl(40 80% 90%); /* amber tint */
--entity-location: hsl(140 60% 90%); /* green tint */
--entity-date: hsl(220 15% 90%); /* slate tint */
--entity-time: hsl(220 15% 90%); /* slate tint */
--entity-currency: hsl(160 60% 90%); /* emerald tint */
--entity-measurement: hsl(190 70% 90%); /* cyan tint */
Dark mode variants shift to lower lightness with higher saturation.
Implementation Sequence
Track 1: Backend — Extraction Pipeline (bowrain server)
TermCandidateAnnotationincore/model/NERProviderinterface incore/ai/ner/AIEntityExtractToolincore/ai/tools/- Azure NER provider in
core/ai/ner/azure/ - Review queue store in
bowrain/store/(SQLite schema + CRUD) - Review queue API endpoints in
bowrain/server/ - Extraction automation rule + worker in
bowrain/event/ - Approval → terms-store creation logic in
bowrain/service/
Track 2: Backend — Editor Entity Support
- Add
entitiesfield toBlockInfoResponseinbowrain/server/editor.go - Entity mutation endpoints (create, update, delete, promote-to-term)
- Entity highlighting in
FormattedSourceDisplayandHighlightedSource(packages/ui/) - Entity popover component (type picker, DNT toggle, promote action)
- Entities section in context panel (right sidebar)
- Manual entity marking: text selection → Cmd+E → type picker
- Code/source editor: colored underlines + sidebar entity list
Track 3: Mobile App (bowrain-app)
- Keycloak PKCE auth flow (device code + deep link callback)
- Workspace selection screen
- Project selection screen
- Review queue screen with
SwipeCardStack TermExtractionCardwired to real APIEntityReviewCard(new card type for entity decisions)- Offline sync engine wired to
/syncendpoint
Track 4: Notifications (incremental)
- Notification store (SQLite schema) + CRUD in
bowrain/store/ - Notification API endpoints (list, read, read-all, delete)
"notify"action executor in automation engine- Notification center UI component in
packages/ui/(glass UI pattern) - Bell icon badge with unread count in
TopBar - WebSocket notification channel (extend
ws_collab.go) - Mobile push notifications via APNs/FCM (bowrain-app)