Implement AI

This commit is contained in:
Chris Chen
2026-06-25 11:11:26 -07:00
parent fa3e75a333
commit a89e936f4d
11 changed files with 377 additions and 5 deletions
@@ -10,10 +10,37 @@
<span>連續登打 / Continuous Entry</span>
</label>
<!-- Description -->
<label class="flex flex-col gap-1 md:col-span-2">Description
<kendo-textbox [(ngModel)]="form.description" placeholder="Brief description of expense"></kendo-textbox>
</label>
<!-- Description (with AI assist: translate to English + suggest a category) -->
<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">Description
<kendo-textbox [(ngModel)]="form.description" placeholder="Brief description of expense / 費用說明(可輸入中文)"></kendo-textbox>
</label>
<button kendoButton fillMode="outline" themeColor="primary" type="button"
[disabled]="!form.description.trim() || aiLoading" (click)="requestAiAssist()"
title="Translate to English and suggest a category / 翻譯並建議分類">
{{ aiLoading ? '思考中… / Thinking…' : '✨ AI 建議' }}
</button>
</div>
<!-- Suggestion card: the "suggest & confirm" step — user applies or dismisses -->
<div *ngIf="hasAiSuggestion" 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="aiSuggestion?.englishDescription" class="flex gap-2">
<span class="text-gray-500 shrink-0">English:</span>
<span class="font-medium">{{ aiSuggestion?.englishDescription }}</span>
</div>
<div *ngIf="aiSuggestion?.groupLabel" class="flex gap-2">
<span class="text-gray-500 shrink-0">分類 / Category:</span>
<span class="font-medium">{{ aiSuggestion?.groupLabel }}<span *ngIf="aiSuggestion?.subLabel"> → {{ aiSuggestion?.subLabel }}</span></span>
</div>
<div class="text-xs text-gray-500">信心 / Confidence: {{ (aiSuggestion?.confidence ?? 0) * 100 | number:'1.0-0' }}%</div>
<div class="flex gap-2">
<button kendoButton themeColor="primary" size="small" type="button" (click)="applyAiSuggestion()">套用 / Apply</button>
<button kendoButton fillMode="flat" size="small" type="button" (click)="dismissAiSuggestion()">忽略 / Dismiss</button>
</div>
</div>
</div>
<!-- Member picker (finance creating on behalf of a member) -->
<label *ngIf="allowMemberPick" class="flex flex-col gap-1 md:col-span-2">Member
<kendo-dropdownlist [data]="memberResults" textField="displayName" valueField="id" [valuePrimitive]="true"
@@ -10,11 +10,12 @@ import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
import { MinistryApiService } from '../../services/ministry-api.service';
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
import { ExpenseApiService } from '../../services/expense-api.service';
import { ExpenseAiService } from '../../services/expense-ai.service';
import { MemberApiService } from '../../../members/services/member-api.service';
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
import {
MinistryDto, ExpenseCategoryGroupDto, ExpenseSubCategoryDto, ExpenseType, CreateExpenseRequest,
ExpenseDto, FunctionalClass,
ExpenseDto, FunctionalClass, ExpenseAiSuggestion,
} from '../../models/expense.model';
export interface ExpenseFormResult {
@@ -101,11 +102,18 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
zoomOut(): void { this.receiptZoom = Math.max(this.minZoom, +(this.receiptZoom - 0.25).toFixed(2)); }
resetZoom(): void { this.receiptZoom = 1; }
// ── AI assist (translate description + suggest category) ────────────────────
/** True while an assist request is in flight (disables the button, shows a spinner label). */
aiLoading = false;
/** The latest suggestion awaiting the user's Apply/Dismiss decision; null when none is shown. */
aiSuggestion: ExpenseAiSuggestion | null = null;
constructor(
private ministryApi: MinistryApiService,
private catApi: ExpenseCategoryApiService,
private memberApi: MemberApiService,
private expenseApi: ExpenseApiService,
private aiApi: ExpenseAiService,
private sanitizer: DomSanitizer,
) {}
@@ -166,6 +174,44 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
line.subs = this.groups.find(g => g.id === groupId)?.subCategories ?? [];
}
/** Ask the backend AI to translate the description and suggest a category; show it for confirmation. */
requestAiAssist(): void {
const text = this.form.description.trim();
if (!text || this.aiLoading) return;
this.aiLoading = true;
this.aiSuggestion = null;
this.aiApi.assist(text, this.totalAmount).subscribe({
next: suggestion => { this.aiSuggestion = suggestion; this.aiLoading = false; },
error: () => { this.aiLoading = false; },
});
}
/** True once a suggestion offers at least a translation or a category to apply. */
get hasAiSuggestion(): boolean {
const s = this.aiSuggestion;
return !!s && (!!s.englishDescription || s.groupId != null);
}
/**
* Apply the suggestion: replace the description with the English translation and set the first
* line's category/sub. Most expenses are single-line; multi-line users adjust the rest by hand.
*/
applyAiSuggestion(): void {
const suggestion = this.aiSuggestion;
if (!suggestion) return;
if (suggestion.englishDescription) this.form.description = suggestion.englishDescription;
if (suggestion.groupId != null) {
const firstLine = this.lines[0];
firstLine.categoryGroupId = suggestion.groupId;
// Populate the sub-category list for the chosen group, then select the suggested sub.
this.onLineGroupChange(firstLine, suggestion.groupId);
if (suggestion.subCategoryId != null) firstLine.subCategoryId = suggestion.subCategoryId;
}
this.aiSuggestion = null;
}
dismissAiSuggestion(): void { this.aiSuggestion = null; }
addLine(): void { this.lines.push(this.emptyLine()); }
removeLine(index: number): void {
@@ -33,6 +33,16 @@ export interface ExpenseDto extends ExpenseListItemDto {
submittedBy: string | null; submittedAt: string | null; paidAt: string | null;
lines: ExpenseLineItemDto[];
}
/** AI assist suggestion: English translation + a proposed major/sub category (null when unclassified). */
export interface ExpenseAiSuggestion {
englishDescription: string | null;
groupId: number | null;
subCategoryId: number | null;
groupLabel: string | null;
subLabel: string | null;
confidence: number;
}
export interface ExpenseLineInput {
categoryGroupId: number; subCategoryId: number; amount: number;
functionalClass: FunctionalClass | null; description: string | null;
@@ -0,0 +1,18 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ApiConfigService } from '../../../core/services/api-config.service';
import { ExpenseAiSuggestion } from '../models/expense.model';
@Injectable({ providedIn: 'root' })
export class ExpenseAiService {
private readonly endpoint: string;
constructor(private http: HttpClient, private apiConfig: ApiConfigService) {
this.endpoint = apiConfig.getApiUrl('expense-ai');
}
/** Ask the backend (which proxies Gemini) to translate the text and suggest a category. */
assist(text: string, amount: number): Observable<ExpenseAiSuggestion> {
return this.http.post<ExpenseAiSuggestion>(`${this.endpoint}/assist`, { text, amount });
}
}