b62e01b153
- Add Comunicare, Instiintare under scrisoare - Add Memoriu justificativ, Studiu de fezabilitate, Audit energetic, Caiet de sarcini under raport - Add Aviz racordare, Acord unic under aviz - Add Contract proiectare, PV predare-primire under contract - Add Borderou, Fisa tehnica, Tema de proiectare, Deviz general, Conventie under altele - Total seed templates: ~55 across 11 document types Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
576 lines
20 KiB
TypeScript
576 lines
20 KiB
TypeScript
/**
|
||
* Subject Template Service — extracts reusable templates from existing
|
||
* registry subjects and provides filtering/assembly utilities.
|
||
*
|
||
* Also provides seed templates per document type so the autocomplete
|
||
* is useful from the very first entry.
|
||
*
|
||
* Pure functions, no React or side effects.
|
||
*/
|
||
|
||
import type { RegistryDirection } from "../types";
|
||
|
||
// ── Types ──
|
||
|
||
export type TemplateFieldType = "nr" | "an" | "detalii" | "proiect" | "text";
|
||
|
||
export interface TemplateField {
|
||
id: string; // "f0", "f1", ...
|
||
name: string; // "nr", "an", "detalii", "proiect"
|
||
placeholder: string; // Romanian label
|
||
width: string; // Tailwind width class
|
||
fieldType?: TemplateFieldType;
|
||
defaultValue?: string;
|
||
}
|
||
|
||
export interface TemplateToken {
|
||
type: "static" | "field";
|
||
value: string; // literal text for static, empty for fields
|
||
field?: TemplateField;
|
||
}
|
||
|
||
export interface SubjectTemplate {
|
||
id: string;
|
||
pattern: string; // "Cerere CU nr. {nr}/{an} — {proiect}"
|
||
tokens: TemplateToken[];
|
||
fields: TemplateField[];
|
||
frequency: number;
|
||
exampleSubject: string;
|
||
/** Whether this is a predefined seed template (not learned from DB) */
|
||
isSeed?: boolean;
|
||
}
|
||
|
||
// ── Internal helpers ──
|
||
|
||
/** Simple hash for template deduplication */
|
||
function hashPattern(s: string): string {
|
||
let h = 0;
|
||
for (let i = 0; i < s.length; i++) {
|
||
h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
||
}
|
||
return `t${Math.abs(h).toString(36)}`;
|
||
}
|
||
|
||
/**
|
||
* Tokenize a subject into static text + variable field segments.
|
||
*
|
||
* Strategy: scan left-to-right, replacing recognized variable patterns with
|
||
* field tokens while keeping everything else as static text.
|
||
*/
|
||
function tokenize(subject: string): TemplateToken[] {
|
||
const tokens: TemplateToken[] = [];
|
||
let fieldIdx = 0;
|
||
|
||
// First check for a separator split (— or – or - with spaces)
|
||
const sepMatch = subject.match(/^(.+?)\s([—–])\s(.+)$/);
|
||
|
||
if (sepMatch) {
|
||
const left = sepMatch[1]!;
|
||
const sep = sepMatch[2]!;
|
||
const right = sepMatch[3]!;
|
||
|
||
// Tokenize the left part (may contain nr/an)
|
||
const leftTokens = tokenizeSegment(left, fieldIdx);
|
||
tokens.push(...leftTokens.tokens);
|
||
fieldIdx = leftTokens.nextIdx;
|
||
|
||
// Add separator as static
|
||
tokens.push({ type: "static", value: ` ${sep} ` });
|
||
|
||
// Right side is always a "detalii" field
|
||
tokens.push({
|
||
type: "field",
|
||
value: "",
|
||
field: {
|
||
id: `f${fieldIdx}`,
|
||
name: "detalii",
|
||
placeholder: "detalii...",
|
||
width: "flex-1 min-w-32",
|
||
fieldType: "detalii",
|
||
},
|
||
});
|
||
} else {
|
||
// No separator — tokenize the whole thing
|
||
const result = tokenizeSegment(subject, fieldIdx);
|
||
tokens.push(...result.tokens);
|
||
}
|
||
|
||
return tokens;
|
||
}
|
||
|
||
/**
|
||
* Tokenize a segment (no separator) by replacing number patterns.
|
||
*/
|
||
function tokenizeSegment(
|
||
segment: string,
|
||
startIdx: number,
|
||
): { tokens: TemplateToken[]; nextIdx: number } {
|
||
const tokens: TemplateToken[] = [];
|
||
let idx = startIdx;
|
||
|
||
// Combined regex: match year (4-digit 20xx) or other numbers (1-6 digits)
|
||
const numPattern = /\b(20\d{2})\b|\b(\d{1,6})\b/g;
|
||
|
||
let lastEnd = 0;
|
||
let match: RegExpExecArray | null;
|
||
|
||
while ((match = numPattern.exec(segment)) !== null) {
|
||
// Static text before this match
|
||
if (match.index > lastEnd) {
|
||
tokens.push({ type: "static", value: segment.slice(lastEnd, match.index) });
|
||
}
|
||
|
||
const isYear = match[1] !== undefined;
|
||
const ft: TemplateFieldType = isYear ? "an" : "nr";
|
||
tokens.push({
|
||
type: "field",
|
||
value: "",
|
||
field: {
|
||
id: `f${idx}`,
|
||
name: isYear ? "an" : "nr",
|
||
placeholder: isYear ? "an" : "nr.",
|
||
width: isYear ? "w-16" : "w-14",
|
||
fieldType: ft,
|
||
},
|
||
});
|
||
idx++;
|
||
lastEnd = match.index + match[0].length;
|
||
}
|
||
|
||
// Trailing static text
|
||
if (lastEnd < segment.length) {
|
||
tokens.push({ type: "static", value: segment.slice(lastEnd) });
|
||
}
|
||
|
||
// If no fields found, the whole segment is a single static token
|
||
if (tokens.length === 0) {
|
||
tokens.push({ type: "static", value: segment });
|
||
}
|
||
|
||
return { tokens, nextIdx: idx };
|
||
}
|
||
|
||
/**
|
||
* Build the pattern string from tokens for grouping/display.
|
||
* E.g. "Cerere CU nr. {nr}/{an} — {detalii}"
|
||
*/
|
||
function buildPattern(tokens: TemplateToken[]): string {
|
||
return tokens
|
||
.map((t) => (t.type === "static" ? t.value : `{${t.field!.name}}`))
|
||
.join("");
|
||
}
|
||
|
||
// ── Seed template builder ──
|
||
|
||
const CURRENT_YEAR = new Date().getFullYear().toString();
|
||
|
||
/**
|
||
* Build a seed SubjectTemplate from a pattern string.
|
||
* Parses `{fieldName}` placeholders into tokens/fields.
|
||
*/
|
||
function seedTemplate(pattern: string, example: string): SubjectTemplate {
|
||
const tokens: TemplateToken[] = [];
|
||
const fields: TemplateField[] = [];
|
||
let fIdx = 0;
|
||
|
||
// Split pattern by {fieldName} placeholders
|
||
const parts = pattern.split(/(\{[^}]+\})/);
|
||
for (const part of parts) {
|
||
if (!part) continue;
|
||
const fieldMatch = part.match(/^\{(\w+)\}$/);
|
||
if (fieldMatch) {
|
||
const name = fieldMatch[1]!;
|
||
const ft = resolveFieldType(name);
|
||
const field: TemplateField = {
|
||
id: `f${fIdx}`,
|
||
name,
|
||
placeholder: fieldPlaceholder(ft),
|
||
width: fieldWidth(ft),
|
||
fieldType: ft,
|
||
defaultValue: ft === "an" ? CURRENT_YEAR : undefined,
|
||
};
|
||
fields.push(field);
|
||
tokens.push({ type: "field", value: "", field });
|
||
fIdx++;
|
||
} else {
|
||
tokens.push({ type: "static", value: part });
|
||
}
|
||
}
|
||
|
||
return {
|
||
id: hashPattern(pattern),
|
||
pattern,
|
||
tokens,
|
||
fields,
|
||
frequency: 0,
|
||
exampleSubject: example,
|
||
isSeed: true,
|
||
};
|
||
}
|
||
|
||
function resolveFieldType(name: string): TemplateFieldType {
|
||
if (name === "nr") return "nr";
|
||
if (name === "an") return "an";
|
||
if (name === "proiect") return "proiect";
|
||
if (name === "detalii") return "detalii";
|
||
return "text";
|
||
}
|
||
|
||
function fieldPlaceholder(ft: TemplateFieldType): string {
|
||
switch (ft) {
|
||
case "nr": return "nr.";
|
||
case "an": return "an";
|
||
case "proiect": return "proiect...";
|
||
case "detalii": return "detalii...";
|
||
case "text": return "text...";
|
||
}
|
||
}
|
||
|
||
function fieldWidth(ft: TemplateFieldType): string {
|
||
switch (ft) {
|
||
case "nr": return "w-14";
|
||
case "an": return "w-16";
|
||
case "proiect": return "flex-1 min-w-32";
|
||
case "detalii": return "flex-1 min-w-32";
|
||
case "text": return "flex-1 min-w-24";
|
||
}
|
||
}
|
||
|
||
// ── Seed Templates Catalog ──
|
||
|
||
const SEED_TEMPLATES: Record<string, SubjectTemplate[]> = {
|
||
cerere: [
|
||
// Cereri trimise (iesit)
|
||
seedTemplate("Cerere CU nr. {nr}/{an} — {proiect}", "Cerere CU nr. 123/2026 — Farmacie Str. Exemplu"),
|
||
seedTemplate("Cerere AC nr. {nr}/{an} — {proiect}", "Cerere AC nr. 456/2026 — Locuinta P+1E"),
|
||
seedTemplate("Cerere prelungire CU nr. {nr}/{an} — {proiect}", "Cerere prelungire CU nr. 78/2026 — Bloc D4"),
|
||
seedTemplate("Cerere prelungire AC nr. {nr}/{an} — {proiect}", "Cerere prelungire AC nr. 90/2026 — Hala industriala"),
|
||
seedTemplate("Cerere completari nr. {nr}/{an} — {proiect}", "Cerere completari nr. 33/2026 — Bloc rezidential"),
|
||
],
|
||
aviz: [
|
||
// Acte administrative primite (intrat)
|
||
seedTemplate("CU nr. {nr}/{an} — {proiect}", "CU nr. 312/2026 — Farmacie Str. Exemplu"),
|
||
seedTemplate("AC nr. {nr}/{an} — {proiect}", "AC nr. 89/2026 — Locuinta P+1E"),
|
||
seedTemplate("Prelungire CU nr. {nr}/{an} — {proiect}", "Prelungire CU nr. 78/2026 — Bloc D4"),
|
||
seedTemplate("Prelungire AC nr. {nr}/{an} — {proiect}", "Prelungire AC nr. 90/2026 — Hala industriala"),
|
||
seedTemplate("Aviz ISU nr. {nr}/{an} — {proiect}", "Aviz ISU nr. 55/2026 — Cladire birouri"),
|
||
seedTemplate("Aviz DSP nr. {nr}/{an} — {proiect}", "Aviz DSP nr. 14/2026 — Gradinita nr. 3"),
|
||
seedTemplate("Aviz Mediu nr. {nr}/{an} — {proiect}", "Aviz Mediu nr. 22/2026 — Hala logistica"),
|
||
seedTemplate("Aviz APM nr. {nr}/{an} — {proiect}", "Aviz APM nr. 8/2026 — PUZ Zona centrala"),
|
||
seedTemplate("Aviz racordare {detalii} nr. {nr}/{an} — {proiect}", "Aviz racordare gaz nr. 31/2026 — Locuinta P+1E"),
|
||
seedTemplate("Aviz {detalii} nr. {nr}/{an} — {proiect}", "Aviz Electrica nr. 44/2026 — Bloc rezidential"),
|
||
seedTemplate("Aviz de oportunitate nr. {nr}/{an} — {proiect}", "Aviz de oportunitate nr. 12/2026 — PUZ Zona centrala"),
|
||
seedTemplate("Acord unic nr. {nr}/{an} — {proiect}", "Acord unic nr. 67/2026 — Cladire birouri"),
|
||
// Solicitari avize (iesit)
|
||
seedTemplate("Solicitare aviz {detalii} — {proiect}", "Solicitare aviz DSP — Gradinita nr. 3"),
|
||
seedTemplate("Solicitare CU — {proiect}", "Solicitare CU — Locuinta unifamiliala"),
|
||
seedTemplate("Solicitare AC — {proiect}", "Solicitare AC — Cladire birouri"),
|
||
],
|
||
contract: [
|
||
seedTemplate("Contract nr. {nr}/{an} — {proiect}", "Contract nr. 15/2026 — Proiectare locuinta"),
|
||
seedTemplate("Contract prestari servicii nr. {nr}/{an} — {proiect}", "Contract prestari servicii nr. 22/2026 — Studiu geo"),
|
||
seedTemplate("Contract proiectare nr. {nr}/{an} — {proiect}", "Contract proiectare nr. 10/2026 — Bloc rezidential"),
|
||
seedTemplate("Act aditional nr. {nr} la contract {detalii}", "Act aditional nr. 2 la contract 15/2026"),
|
||
seedTemplate("PV predare-primire nr. {nr}/{an} — {proiect}", "PV predare-primire nr. 4/2026 — Cladire birouri"),
|
||
],
|
||
oferta: [
|
||
seedTemplate("Oferta proiectare {detalii} — {proiect}", "Oferta proiectare DTAC+PT — Bloc rezidential"),
|
||
seedTemplate("Oferta nr. {nr}/{an} — {proiect}", "Oferta nr. 8/2026 — Mansardare bloc"),
|
||
],
|
||
factura: [
|
||
seedTemplate("Factura nr. {nr}/{an} — {proiect}", "Factura nr. 102/2026 — Farmacie Str. Exemplu"),
|
||
seedTemplate("Factura proforma nr. {nr}/{an} — {proiect}", "Factura proforma nr. 5/2026 — Studiu geo"),
|
||
],
|
||
scrisoare: [
|
||
seedTemplate("Adresa nr. {nr}/{an} — {detalii}", "Adresa nr. 45/2026 — Raspuns solicitare informatii"),
|
||
seedTemplate("Comunicare {detalii} — {proiect}", "Comunicare rezultat analiza — Bloc rezidential"),
|
||
seedTemplate("Comunicare nr. {nr}/{an} — {proiect}", "Comunicare nr. 18/2026 — Farmacie Str. Exemplu"),
|
||
seedTemplate("Notificare {detalii} — {proiect}", "Notificare incepere lucrari — Locuinta P+1E"),
|
||
seedTemplate("Adeverinta {detalii} nr. {nr}/{an}", "Adeverinta stagiu practica nr. 3/2026"),
|
||
seedTemplate("Raspuns completari nr. {nr}/{an} — {proiect}", "Raspuns completari nr. 33/2026 — Bloc rezidential"),
|
||
seedTemplate("Instiintare {detalii} — {proiect}", "Instiintare incepere executie — Locuinta P+1E"),
|
||
seedTemplate("Somatie {detalii} — {proiect}", "Somatie plata factura — Cladire birouri"),
|
||
],
|
||
"nota-de-comanda": [
|
||
seedTemplate("Nota de comanda nr. {nr}/{an} — {proiect}", "Nota de comanda nr. 7/2026 — Instalatii electrice"),
|
||
seedTemplate("Comanda {detalii} — {proiect}", "Comanda materiale — Santier Bloc A"),
|
||
],
|
||
raport: [
|
||
seedTemplate("Raport {detalii} — {proiect}", "Raport expertiza tehnica — Consolidare bloc"),
|
||
seedTemplate("Memoriu justificativ — {proiect}", "Memoriu justificativ — Locuinta P+1E"),
|
||
seedTemplate("Memoriu justificativ {detalii} — {proiect}", "Memoriu justificativ arhitectura — Bloc rezidential"),
|
||
seedTemplate("Studiu geotehnic {detalii} — {proiect}", "Studiu geotehnic preliminar — Hala logistica"),
|
||
seedTemplate("Studiu de fezabilitate — {proiect}", "Studiu de fezabilitate — Reabilitare scoala"),
|
||
seedTemplate("Raport nr. {nr}/{an} — {proiect}", "Raport nr. 3/2026 — Audit energetic"),
|
||
seedTemplate("Expertiza tehnica nr. {nr}/{an} — {proiect}", "Expertiza tehnica nr. 7/2026 — Consolidare bloc"),
|
||
seedTemplate("RTE nr. {nr}/{an} — {proiect}", "RTE nr. 12/2026 — Reabilitare termica bloc"),
|
||
seedTemplate("Audit energetic nr. {nr}/{an} — {proiect}", "Audit energetic nr. 4/2026 — Bloc C3"),
|
||
seedTemplate("Caiet de sarcini — {proiect}", "Caiet de sarcini — Instalatii electrice"),
|
||
],
|
||
"apel-telefonic": [
|
||
seedTemplate("Convorbire {detalii} — {proiect}", "Convorbire primarie avize — Bloc D4"),
|
||
seedTemplate("Apel {detalii} — {proiect}", "Apel ISU programare — Gradinita nr. 3"),
|
||
],
|
||
videoconferinta: [
|
||
seedTemplate("Sedinta {detalii} — {proiect}", "Sedinta coordonare proiectare — Bloc rezidential"),
|
||
seedTemplate("Videoconferinta {detalii} — {proiect}", "Videoconferinta revizie PT — Hala industriala"),
|
||
],
|
||
altele: [
|
||
seedTemplate("Documentatie {detalii} — {proiect}", "Documentatie PAC+DDE — Locuinta P+M"),
|
||
seedTemplate("PV receptie nr. {nr}/{an} — {proiect}", "PV receptie nr. 5/2026 — Cladire birouri"),
|
||
seedTemplate("Proces verbal nr. {nr}/{an} — {proiect}", "Proces verbal nr. 3/2026 — Inspectie santier"),
|
||
seedTemplate("Referat verificare nr. {nr}/{an} — {proiect}", "Referat verificare nr. 11/2026 — Locuinta P+1E"),
|
||
seedTemplate("Memoriu tehnic — {proiect}", "Memoriu tehnic — Hala industriala"),
|
||
seedTemplate("Decizie {detalii} nr. {nr}/{an}", "Decizie etapizare nr. 2/2026"),
|
||
seedTemplate("Borderou {detalii} — {proiect}", "Borderou piese scrise — Cladire birouri"),
|
||
seedTemplate("Fisa tehnica {detalii} — {proiect}", "Fisa tehnica echipament HVAC — Bloc rezidential"),
|
||
seedTemplate("Tema de proiectare — {proiect}", "Tema de proiectare — Locuinta unifamiliala"),
|
||
seedTemplate("Deviz general — {proiect}", "Deviz general — Reabilitare scoala"),
|
||
seedTemplate("Conventie {detalii} nr. {nr}/{an}", "Conventie colaborare nr. 2/2026"),
|
||
],
|
||
};
|
||
|
||
// ── Dynamic Placeholders ──
|
||
|
||
const SUBJECT_PLACEHOLDERS_MAP: Record<string, Record<string, string>> = {
|
||
cerere: {
|
||
intrat: "ex: Cerere completari nr. 33/2026 — Proiect X",
|
||
iesit: "ex: Cerere CU nr. 123/2026 — Proiect X",
|
||
},
|
||
aviz: {
|
||
intrat: "ex: CU nr. 312/2026 — Proiect X",
|
||
iesit: "ex: Solicitare aviz DSP — Proiect X",
|
||
},
|
||
contract: {
|
||
intrat: "ex: Contract nr. 15/2026 — Proiect X",
|
||
iesit: "ex: Contract prestari servicii nr. 22/2026 — Proiect X",
|
||
},
|
||
oferta: {
|
||
intrat: "ex: Oferta nr. 8/2026 — Proiect X",
|
||
iesit: "ex: Oferta proiectare DTAC+PT — Proiect X",
|
||
},
|
||
factura: {
|
||
intrat: "ex: Factura nr. 102/2026 — Proiect X",
|
||
iesit: "ex: Factura proforma nr. 5/2026 — Proiect X",
|
||
},
|
||
scrisoare: {
|
||
intrat: "ex: Adresa nr. 45/2026 — Raspuns informatii",
|
||
iesit: "ex: Notificare incepere lucrari — Proiect X",
|
||
},
|
||
"nota-de-comanda": {
|
||
intrat: "ex: Nota de comanda nr. 7/2026 — Proiect X",
|
||
iesit: "ex: Comanda materiale — Proiect X",
|
||
},
|
||
raport: {
|
||
intrat: "ex: Expertiza tehnica nr. 7/2026 — Proiect X",
|
||
iesit: "ex: Raport nr. 3/2026 — Proiect X",
|
||
},
|
||
"apel-telefonic": {
|
||
intrat: "ex: Apel primarie avize — Proiect X",
|
||
iesit: "ex: Convorbire ISU programare — Proiect X",
|
||
},
|
||
videoconferinta: {
|
||
intrat: "ex: Sedinta coordonare — Proiect X",
|
||
iesit: "ex: Videoconferinta revizie PT — Proiect X",
|
||
},
|
||
altele: {
|
||
intrat: "ex: PV receptie nr. 5/2026 — Proiect X",
|
||
iesit: "ex: Documentatie PAC+DDE — Proiect X",
|
||
},
|
||
};
|
||
|
||
const DEFAULT_PLACEHOLDER = "Tastati pentru sugestii...";
|
||
|
||
/**
|
||
* Get a dynamic placeholder for the subject input based on document type
|
||
* and direction.
|
||
*/
|
||
export function getSubjectPlaceholder(
|
||
documentType: string,
|
||
direction: RegistryDirection,
|
||
): string {
|
||
const byType = SUBJECT_PLACEHOLDERS_MAP[documentType];
|
||
if (!byType) return DEFAULT_PLACEHOLDER;
|
||
return byType[direction] ?? DEFAULT_PLACEHOLDER;
|
||
}
|
||
|
||
// ── Public API ──
|
||
|
||
/**
|
||
* Get seed templates for a given document type.
|
||
*/
|
||
export function getSeedTemplates(documentType: string): SubjectTemplate[] {
|
||
return SEED_TEMPLATES[documentType] ?? SEED_TEMPLATES["altele"] ?? [];
|
||
}
|
||
|
||
/**
|
||
* Get recommended templates for a document type: merge seeds + DB-learned.
|
||
* DB-learned templates take priority (appear first), seeds fill in the rest.
|
||
* De-duplicates by pattern.
|
||
*/
|
||
export function getRecommendedTemplates(
|
||
documentType: string,
|
||
dbTemplates: SubjectTemplate[],
|
||
): SubjectTemplate[] {
|
||
const seeds = getSeedTemplates(documentType);
|
||
const seen = new Set<string>();
|
||
const result: SubjectTemplate[] = [];
|
||
|
||
// DB-learned first (higher priority)
|
||
for (const t of dbTemplates) {
|
||
if (!seen.has(t.pattern)) {
|
||
seen.add(t.pattern);
|
||
result.push(t);
|
||
}
|
||
}
|
||
|
||
// Then seeds (fill remaining slots)
|
||
for (const t of seeds) {
|
||
if (!seen.has(t.pattern)) {
|
||
seen.add(t.pattern);
|
||
result.push(t);
|
||
}
|
||
}
|
||
|
||
return result.slice(0, 10);
|
||
}
|
||
|
||
/**
|
||
* Extract reusable templates from an array of existing subjects.
|
||
* Groups by pattern, counts frequency, returns sorted by frequency desc.
|
||
*/
|
||
export function extractTemplates(subjects: string[]): SubjectTemplate[] {
|
||
if (subjects.length === 0) return [];
|
||
|
||
// Deduplicate subjects
|
||
const unique = Array.from(new Set(subjects));
|
||
|
||
// Tokenize each subject and group by pattern
|
||
const groups = new Map<
|
||
string,
|
||
{ tokens: TemplateToken[]; example: string; count: number }
|
||
>();
|
||
|
||
for (const subj of unique) {
|
||
// Skip very short subjects (no useful template)
|
||
if (subj.length < 3) continue;
|
||
|
||
const tokens = tokenize(subj);
|
||
const fields = tokens.filter((t) => t.type === "field");
|
||
|
||
// Only consider subjects that have at least one field (variable part)
|
||
if (fields.length === 0) continue;
|
||
|
||
const pattern = buildPattern(tokens);
|
||
const existing = groups.get(pattern);
|
||
if (existing) {
|
||
existing.count++;
|
||
} else {
|
||
groups.set(pattern, { tokens, example: subj, count: 1 });
|
||
}
|
||
}
|
||
|
||
// Count actual frequency: for each pattern, count how many of the
|
||
// original (non-unique) subjects match
|
||
// (Already counting unique matches above; for frequency boost, count
|
||
// duplicates from the full input array)
|
||
const fullCounts = new Map<string, number>();
|
||
for (const subj of subjects) {
|
||
if (subj.length < 3) continue;
|
||
const tokens = tokenize(subj);
|
||
const fields = tokens.filter((t) => t.type === "field");
|
||
if (fields.length === 0) continue;
|
||
const pattern = buildPattern(tokens);
|
||
fullCounts.set(pattern, (fullCounts.get(pattern) ?? 0) + 1);
|
||
}
|
||
|
||
// Build result array
|
||
const result: SubjectTemplate[] = [];
|
||
|
||
for (const [pattern, group] of groups) {
|
||
const freq = fullCounts.get(pattern) ?? group.count;
|
||
|
||
// Rebuild fields array from tokens (with sequential IDs)
|
||
const fields: TemplateField[] = [];
|
||
let fIdx = 0;
|
||
const normalizedTokens: TemplateToken[] = group.tokens.map((t) => {
|
||
if (t.type === "field" && t.field) {
|
||
const field: TemplateField = { ...t.field, id: `f${fIdx}` };
|
||
fields.push(field);
|
||
fIdx++;
|
||
return { ...t, field };
|
||
}
|
||
return t;
|
||
});
|
||
|
||
result.push({
|
||
id: hashPattern(pattern),
|
||
pattern,
|
||
tokens: normalizedTokens,
|
||
fields,
|
||
frequency: freq,
|
||
exampleSubject: group.example,
|
||
});
|
||
}
|
||
|
||
// Sort by frequency desc, limit to 15
|
||
result.sort((a, b) => b.frequency - a.frequency);
|
||
return result.slice(0, 15);
|
||
}
|
||
|
||
/**
|
||
* Assemble a final subject string from a template + field values.
|
||
*/
|
||
export function assembleSubject(
|
||
template: SubjectTemplate,
|
||
fieldValues: Record<string, string>,
|
||
): string {
|
||
return template.tokens
|
||
.map((t) => {
|
||
if (t.type === "static") return t.value;
|
||
return fieldValues[t.field!.id] ?? "";
|
||
})
|
||
.join("");
|
||
}
|
||
|
||
/**
|
||
* Filter templates by a query string.
|
||
* Matches against both the pattern and the example subject.
|
||
*/
|
||
export function filterTemplates(
|
||
templates: SubjectTemplate[],
|
||
query: string,
|
||
): SubjectTemplate[] {
|
||
if (!query || query.length < 2) return [];
|
||
const q = query.toLowerCase();
|
||
return templates.filter(
|
||
(t) =>
|
||
t.pattern.toLowerCase().includes(q) ||
|
||
t.exampleSubject.toLowerCase().includes(q),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Filter existing subjects by query (plain text substring match).
|
||
* Returns unique matches, limited to `limit`.
|
||
*/
|
||
export function filterSubjects(
|
||
subjects: string[],
|
||
query: string,
|
||
limit = 5,
|
||
): string[] {
|
||
if (!query || query.length < 2) return [];
|
||
const q = query.toLowerCase();
|
||
const seen = new Set<string>();
|
||
const result: string[] = [];
|
||
for (const s of subjects) {
|
||
if (result.length >= limit) break;
|
||
const lower = s.toLowerCase();
|
||
if (lower.includes(q) && !seen.has(lower)) {
|
||
seen.add(lower);
|
||
result.push(s);
|
||
}
|
||
}
|
||
return result;
|
||
}
|