feat(expense-categories): AI 建議 for group/sub name + 990 line
ci-cd-vm / ci-cd (push) Successful in 2m25s

Add an AI assist button to the Edit/New Group (大項) and Subcategory
(小項) dialogs: the user enters a Chinese name, and the model refines
the Chinese, translates it to English, and suggests the matching IRS
Form 990 Part IX line. Suggestions surface in a confirm card; Apply
fills the Chinese name, English name, and 990 line fields.

Backend mirrors the existing expense-classification AI family but over
the Form 990 line catalog: IExpenseCategoryAiService + base (catalog
load, prompt, id validation) + Claude/Gemini providers + factory that
picks the provider from ChurchProfile.AiProvider. New write-gated
POST api/expense-categories/ai-suggest endpoint; sub-category requests
pass the parent group + its 990 line to bias the choice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Chris Chen
2026-06-25 14:18:09 -07:00
parent c5b1a9372a
commit 73077295a4
14 changed files with 682 additions and 11 deletions
@@ -106,7 +106,7 @@
<!-- Row 2: Description (optional) + Amount -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<label class="flex flex-col gap-1">Description / 說明 <span class="text-gray-400">(Optional)</span>
<label class="flex flex-col gap-1"><span>Description / 說明 <span class="text-gray-400">(Optional)</span></span>
<kendo-textbox [(ngModel)]="line.description" placeholder="e.g. 點心、文具…"></kendo-textbox>
</label>
@@ -44,6 +44,23 @@ export interface ExpenseAiSuggestion {
confidence: number;
}
/** Request to AI-assist defining an expense category (大項/小項). */
export interface ExpenseCategoryAiRequest {
name_zh: string;
name_en?: string | null;
level: 'group' | 'sub';
parentGroupName?: string | null;
parentForm990LineId?: number | null;
}
/** AI suggestion for a category: refined Chinese name, English translation, and a Form 990 line. */
export interface CategoryAiSuggestion {
chineseName: string | null;
englishName: string | null;
form990LineId: number | null;
form990LineLabel: string | null;
confidence: number;
}
export interface ExpenseLineInput {
categoryGroupId: number; subCategoryId: number; amount: number;
functionalClass: FunctionalClass | null; description: string | null;
@@ -53,10 +53,40 @@
Name (EN) *
<kendo-textbox [(ngModel)]="groupForm.name_en"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
名稱 (中)
<kendo-textbox [(ngModel)]="groupForm.name_zh"></kendo-textbox>
</label>
<!-- Chinese name with AI assist: refine 中文 + translate to English + suggest a 990 line -->
<div class="flex flex-col gap-1 md:col-span-2">
<div class="flex items-end justify-between gap-2">
<label class="flex flex-1 flex-col gap-1">名稱 (中)
<kendo-textbox [(ngModel)]="groupForm.name_zh" placeholder="可輸入中文,AI 幫忙翻譯"></kendo-textbox>
</label>
<button kendoButton fillMode="outline" themeColor="primary" type="button"
[disabled]="(!groupForm.name_zh.trim() && !groupForm.name_en.trim()) || groupAiLoading"
(click)="requestGroupAiSuggest()"
title="翻譯英文並建議 990 Line / Translate + suggest 990 line">
{{ groupAiLoading ? '思考中… / Thinking…' : '✨ AI 建議' }}
</button>
</div>
<div *ngIf="groupAiSuggestion" class="rounded border border-blue-200 bg-blue-50 p-3 flex flex-col gap-2 text-sm">
<div class="font-semibold text-blue-800">AI 建議 / Suggestion</div>
<div *ngIf="groupAiSuggestion.englishName" class="flex gap-2">
<span class="text-gray-500 shrink-0">English:</span>
<span class="font-medium">{{ groupAiSuggestion.englishName }}</span>
</div>
<div *ngIf="groupAiSuggestion.chineseName" class="flex gap-2">
<span class="text-gray-500 shrink-0">中文:</span>
<span class="font-medium">{{ groupAiSuggestion.chineseName }}</span>
</div>
<div *ngIf="groupAiSuggestion.form990LineLabel" class="flex gap-2">
<span class="text-gray-500 shrink-0">990 Line:</span>
<span class="font-medium">{{ groupAiSuggestion.form990LineLabel }}</span>
</div>
<div class="text-xs text-gray-500">信心 / Confidence: {{ groupAiSuggestion.confidence * 100 | number:'1.0-0' }}%</div>
<div class="flex gap-2">
<button kendoButton themeColor="primary" size="small" type="button" (click)="applyGroupAiSuggestion()">套用 / Apply</button>
<button kendoButton fillMode="flat" size="small" type="button" (click)="dismissGroupAiSuggestion()">忽略 / Dismiss</button>
</div>
</div>
</div>
<label class="flex flex-col gap-1">
Sort order
<kendo-numerictextbox [(ngModel)]="groupForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
@@ -90,10 +120,40 @@
Name (EN) *
<kendo-textbox [(ngModel)]="subForm.name_en"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
名稱 (中)
<kendo-textbox [(ngModel)]="subForm.name_zh"></kendo-textbox>
</label>
<!-- Chinese name with AI assist: refine 中文 + translate to English + suggest a 990 line (biased by parent group) -->
<div class="flex flex-col gap-1 md:col-span-2">
<div class="flex items-end justify-between gap-2">
<label class="flex flex-1 flex-col gap-1">名稱 (中)
<kendo-textbox [(ngModel)]="subForm.name_zh" placeholder="可輸入中文,AI 幫忙翻譯"></kendo-textbox>
</label>
<button kendoButton fillMode="outline" themeColor="primary" type="button"
[disabled]="(!subForm.name_zh.trim() && !subForm.name_en.trim()) || subAiLoading"
(click)="requestSubAiSuggest()"
title="翻譯英文並建議 990 Line / Translate + suggest 990 line">
{{ subAiLoading ? '思考中… / Thinking…' : '✨ AI 建議' }}
</button>
</div>
<div *ngIf="subAiSuggestion" class="rounded border border-blue-200 bg-blue-50 p-3 flex flex-col gap-2 text-sm">
<div class="font-semibold text-blue-800">AI 建議 / Suggestion</div>
<div *ngIf="subAiSuggestion.englishName" class="flex gap-2">
<span class="text-gray-500 shrink-0">English:</span>
<span class="font-medium">{{ subAiSuggestion.englishName }}</span>
</div>
<div *ngIf="subAiSuggestion.chineseName" class="flex gap-2">
<span class="text-gray-500 shrink-0">中文:</span>
<span class="font-medium">{{ subAiSuggestion.chineseName }}</span>
</div>
<div *ngIf="subAiSuggestion.form990LineLabel" class="flex gap-2">
<span class="text-gray-500 shrink-0">990 Line:</span>
<span class="font-medium">{{ subAiSuggestion.form990LineLabel }}</span>
</div>
<div class="text-xs text-gray-500">信心 / Confidence: {{ subAiSuggestion.confidence * 100 | number:'1.0-0' }}%</div>
<div class="flex gap-2">
<button kendoButton themeColor="primary" size="small" type="button" (click)="applySubAiSuggestion()">套用 / Apply</button>
<button kendoButton fillMode="flat" size="small" type="button" (click)="dismissSubAiSuggestion()">忽略 / Dismiss</button>
</div>
</div>
</div>
<label class="flex flex-col gap-1">
Sort order
<kendo-numerictextbox [(ngModel)]="subForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
@@ -8,7 +8,7 @@ import { InputsModule } from '@progress/kendo-angular-inputs';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
import { ExpenseCategoryGroupDto, ExpenseSubCategoryDto } from '../../models/expense.model';
import { ExpenseCategoryGroupDto, ExpenseSubCategoryDto, CategoryAiSuggestion } from '../../models/expense.model';
import { Form990ExpenseLineDto } from '../../../finance-report/models/form990-report.model';
@Component({
@@ -34,10 +34,14 @@ export class ExpenseCategoriesPageComponent implements OnInit {
groupDialogOpen = false;
editingGroupId: number | null = null;
groupForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null };
groupAiLoading = false;
groupAiSuggestion: CategoryAiSuggestion | null = null;
subDialogOpen = false;
editingSubId: number | null = null;
subForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null };
subAiLoading = false;
subAiSuggestion: CategoryAiSuggestion | null = null;
constructor(private api: ExpenseCategoryApiService) {}
@@ -108,13 +112,36 @@ export class ExpenseCategoriesPageComponent implements OnInit {
openNewGroup(): void {
this.editingGroupId = null;
this.groupForm = { name_en: '', name_zh: '', sortOrder: this.groups.length + 1, isActive: true, form990LineId: null };
this.resetGroupAi();
this.groupDialogOpen = true;
}
openEditGroup(g: ExpenseCategoryGroupDto): void {
this.editingGroupId = g.id;
this.groupForm = { name_en: g.name_en, name_zh: g.name_zh ?? '', sortOrder: g.sortOrder, isActive: g.isActive, form990LineId: g.form990LineId };
this.resetGroupAi();
this.groupDialogOpen = true;
}
// ── Group AI assist: refine the Chinese name, translate to English, suggest a Form 990 line ──
private resetGroupAi(): void { this.groupAiLoading = false; this.groupAiSuggestion = null; }
requestGroupAiSuggest(): void {
if (this.groupAiLoading) return;
this.groupAiLoading = true;
this.groupAiSuggestion = null;
this.api.aiSuggest({ name_zh: this.groupForm.name_zh.trim(), name_en: this.groupForm.name_en.trim() || null, level: 'group' }).subscribe({
next: s => { this.groupAiSuggestion = s; this.groupAiLoading = false; },
error: () => { this.groupAiLoading = false; },
});
}
applyGroupAiSuggestion(): void {
const s = this.groupAiSuggestion;
if (!s) return;
if (s.chineseName) this.groupForm.name_zh = s.chineseName;
if (s.englishName) this.groupForm.name_en = s.englishName;
if (s.form990LineId != null) this.groupForm.form990LineId = s.form990LineId;
this.groupAiSuggestion = null;
}
dismissGroupAiSuggestion(): void { this.groupAiSuggestion = null; }
saveGroup(): void {
const body = { name_en: this.groupForm.name_en, name_zh: this.groupForm.name_zh || null, sortOrder: this.groupForm.sortOrder, form990LineId: this.groupForm.form990LineId };
const done = () => { this.groupDialogOpen = false; this.load(); };
@@ -130,13 +157,42 @@ export class ExpenseCategoriesPageComponent implements OnInit {
if (!this.selectedGroup) return;
this.editingSubId = null;
this.subForm = { name_en: '', name_zh: '', sortOrder: this.subCategories.length + 1, isActive: true, form990LineId: null };
this.resetSubAi();
this.subDialogOpen = true;
}
openEditSub(s: ExpenseSubCategoryDto): void {
this.editingSubId = s.id;
this.subForm = { name_en: s.name_en, name_zh: s.name_zh ?? '', sortOrder: s.sortOrder, isActive: s.isActive, form990LineId: s.form990LineId };
this.resetSubAi();
this.subDialogOpen = true;
}
// ── Subcategory AI assist: same as group, biased by the selected parent group + its 990 line ──
private resetSubAi(): void { this.subAiLoading = false; this.subAiSuggestion = null; }
requestSubAiSuggest(): void {
if (this.subAiLoading || !this.selectedGroup) return;
this.subAiLoading = true;
this.subAiSuggestion = null;
this.api.aiSuggest({
name_zh: this.subForm.name_zh.trim(),
name_en: this.subForm.name_en.trim() || null,
level: 'sub',
parentGroupName: this.selectedGroup.label ?? this.selectedGroup.name_en,
parentForm990LineId: this.selectedGroup.form990LineId,
}).subscribe({
next: s => { this.subAiSuggestion = s; this.subAiLoading = false; },
error: () => { this.subAiLoading = false; },
});
}
applySubAiSuggestion(): void {
const s = this.subAiSuggestion;
if (!s) return;
if (s.chineseName) this.subForm.name_zh = s.chineseName;
if (s.englishName) this.subForm.name_en = s.englishName;
if (s.form990LineId != null) this.subForm.form990LineId = s.form990LineId;
this.subAiSuggestion = null;
}
dismissSubAiSuggestion(): void { this.subAiSuggestion = null; }
saveSub(): void {
if (!this.selectedGroup) return;
const body = { groupId: this.selectedGroup.id, name_en: this.subForm.name_en, name_zh: this.subForm.name_zh || null, sortOrder: this.subForm.sortOrder, form990LineId: this.subForm.form990LineId };
@@ -6,6 +6,7 @@ import { ApiConfigService } from '../../../core/services/api-config.service';
import {
ExpenseCategoryGroupDto, CreateExpenseGroupRequest, UpdateExpenseGroupRequest,
CreateExpenseSubCategoryRequest, UpdateExpenseSubCategoryRequest,
ExpenseCategoryAiRequest, CategoryAiSuggestion,
} from '../models/expense.model';
import { Form990ExpenseLineDto } from '../../finance-report/models/form990-report.model';
@@ -30,6 +31,9 @@ export class ExpenseCategoryApiService {
createSub(r: CreateExpenseSubCategoryRequest): Observable<{ id: number }> { return this.http.post<{ id: number }>(`${this.endpoint}/subcategories`, r); }
updateSub(id: number, r: UpdateExpenseSubCategoryRequest): Observable<void> { return this.http.put<void>(`${this.endpoint}/subcategories/${id}`, r); }
deactivateSub(id: number): Observable<void> { return this.http.delete<void>(`${this.endpoint}/subcategories/${id}`); }
aiSuggest(req: ExpenseCategoryAiRequest): Observable<CategoryAiSuggestion> {
return this.http.post<CategoryAiSuggestion>(`${this.endpoint}/ai-suggest`, req);
}
getForm990Lines(): Observable<Form990ExpenseLineDto[]> {
return this.http.get<Form990ExpenseLineDto[]>(this.apiConfig.getApiUrl('form990-report') + '/lines')
.pipe(map(rows => rows.map(r => ({ ...r, label: `${r.lineCode}${r.name_en}${r.name_zh ? ' / ' + r.name_zh : ''}` }))));