Files
ArchiTools/src/modules/registratura/services/subject-template-service.ts
T
AI Assistant b62e01b153 feat(registratura): expand seed templates — memoriu justificativ, comunicare, deviz, borderou
- 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>
2026-03-10 13:31:36 +02:00

576 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}