Implement AI
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.DTOs.Expense;
|
||||
using ROLAC.API.Services.Ai;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/expense-ai")]
|
||||
[Authorize] // Open to any authenticated user — same audience as the expense-entry form, which any
|
||||
// member filing a reimbursement can reach. The endpoint only reads the category catalog.
|
||||
public class ExpenseAiController : ControllerBase
|
||||
{
|
||||
private readonly IExpenseAiService _svc;
|
||||
public ExpenseAiController(IExpenseAiService svc) => _svc = svc;
|
||||
|
||||
[HttpPost("assist")]
|
||||
public async Task<IActionResult> Assist([FromBody] ExpenseAiAssistRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Text))
|
||||
return BadRequest("Text is required.");
|
||||
|
||||
var suggestion = await _svc.SuggestAsync(request.Text, request.Amount, ct);
|
||||
return Ok(suggestion);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
namespace ROLAC.API.DTOs.Expense;
|
||||
|
||||
/// <summary>Request body for the expense AI assist endpoint.</summary>
|
||||
public class ExpenseAiAssistRequest
|
||||
{
|
||||
/// <summary>The user's free-text expense description (typically Chinese).</summary>
|
||||
[Required] public string Text { get; set; } = "";
|
||||
/// <summary>The expense amount, used as a hint when classifying the category.</summary>
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AI suggestion for an expense: an English translation of the description plus a proposed
|
||||
/// major category (大項) and sub-category (系項). Category ids are null when the model could
|
||||
/// not confidently classify or returned an id outside the live catalog.
|
||||
/// </summary>
|
||||
public class ExpenseAiSuggestion
|
||||
{
|
||||
public string? EnglishDescription { get; set; }
|
||||
public int? GroupId { get; set; }
|
||||
public int? SubCategoryId { get; set; }
|
||||
/// <summary>Bilingual label of the suggested group, e.g. "Consumables / 消耗品".</summary>
|
||||
public string? GroupLabel { get; set; }
|
||||
/// <summary>Bilingual label of the suggested sub-category, e.g. "Batteries / 電池".</summary>
|
||||
public string? SubLabel { get; set; }
|
||||
/// <summary>Model self-reported confidence in the classification, 0..1.</summary>
|
||||
public double Confidence { get; set; }
|
||||
}
|
||||
@@ -179,6 +179,12 @@ builder.Services.AddScoped<ROLAC.API.Services.Notifications.ILineNotificationSer
|
||||
builder.Services.AddHttpClient<ROLAC.API.Services.Notifications.IMessageChannel,
|
||||
ROLAC.API.Services.Notifications.LineMessageChannel>();
|
||||
|
||||
// ── AI assist (Google Gemini) ──────────────────────────────────────────────
|
||||
// Backend proxy for expense translation + category suggestion; the API key stays server-side.
|
||||
builder.Services.Configure<ROLAC.API.Services.Ai.GeminiOptions>(config.GetSection("Gemini"));
|
||||
builder.Services.AddHttpClient<ROLAC.API.Services.Ai.IExpenseAiService,
|
||||
ROLAC.API.Services.Ai.GeminiExpenseAiService>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configurable role-based permissions (RBAC matrix)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.DTOs.Expense;
|
||||
|
||||
namespace ROLAC.API.Services.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// Calls the Google Gemini <c>generateContent</c> API to translate an expense description and
|
||||
/// classify it into the church's existing expense category catalog (大項 / 系項). The full active
|
||||
/// catalog is sent in the prompt so the model can only choose from real ids; any id it returns is
|
||||
/// re-validated against the catalog before being surfaced, so a hallucinated id is dropped, not echoed.
|
||||
/// </summary>
|
||||
public sealed class GeminiExpenseAiService : IExpenseAiService
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly GeminiOptions _options;
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ILogger<GeminiExpenseAiService> _logger;
|
||||
|
||||
public GeminiExpenseAiService(
|
||||
HttpClient http,
|
||||
IOptions<GeminiOptions> options,
|
||||
AppDbContext db,
|
||||
ILogger<GeminiExpenseAiService> logger)
|
||||
{
|
||||
_http = http;
|
||||
_options = options.Value;
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ExpenseAiSuggestion> SuggestAsync(string chineseText, decimal amount, CancellationToken ct = default)
|
||||
{
|
||||
// Load the active catalog: the allow-list the model must classify into.
|
||||
var groups = await _db.ExpenseCategoryGroups
|
||||
.AsNoTracking()
|
||||
.Where(group => group.IsActive)
|
||||
.OrderBy(group => group.SortOrder)
|
||||
.Select(group => new
|
||||
{
|
||||
group.Id,
|
||||
group.Name_en,
|
||||
group.Name_zh,
|
||||
Subs = group.SubCategories
|
||||
.Where(sub => sub.IsActive)
|
||||
.OrderBy(sub => sub.SortOrder)
|
||||
.Select(sub => new { sub.Id, sub.Name_en, sub.Name_zh })
|
||||
.ToList(),
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.ApiKey))
|
||||
{
|
||||
_logger.LogWarning("Gemini API key is not configured; expense AI assist is disabled.");
|
||||
return new ExpenseAiSuggestion();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var catalogJson = JsonSerializer.Serialize(groups);
|
||||
var prompt =
|
||||
"You are a bookkeeping assistant for a church. Given an expense description (often in " +
|
||||
"Traditional Chinese) and its amount, do two things:\n" +
|
||||
"1. Translate the description into concise, natural accounting English (a short noun phrase, " +
|
||||
"not a full sentence).\n" +
|
||||
"2. Choose the single best matching major category (group) and sub-category from the catalog " +
|
||||
"below. You MUST pick a groupId and subCategoryId that appear in the catalog, and the " +
|
||||
"subCategoryId must belong to that groupId. If nothing fits well, choose the closest " +
|
||||
"\"Other / 其他\" option and lower your confidence.\n\n" +
|
||||
$"Expense description: {chineseText}\n" +
|
||||
$"Amount: {amount}\n\n" +
|
||||
$"Category catalog (JSON; each group has an id, English/Chinese names, and its sub-categories):\n{catalogJson}\n\n" +
|
||||
"Respond with JSON: englishDescription (string), groupId (integer), subCategoryId (integer), " +
|
||||
"confidence (number 0..1).";
|
||||
|
||||
var payload = new
|
||||
{
|
||||
contents = new[]
|
||||
{
|
||||
new { parts = new[] { new { text = prompt } } },
|
||||
},
|
||||
generationConfig = new
|
||||
{
|
||||
responseMimeType = "application/json",
|
||||
responseSchema = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
englishDescription = new { type = "string" },
|
||||
groupId = new { type = "integer" },
|
||||
subCategoryId = new { type = "integer" },
|
||||
confidence = new { type = "number" },
|
||||
},
|
||||
required = new[] { "englishDescription", "groupId", "subCategoryId", "confidence" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var url = $"{_options.BaseUrl}/models/{_options.Model}:generateContent";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
{
|
||||
Content = JsonContent.Create(payload),
|
||||
};
|
||||
request.Headers.Add("X-goog-api-key", _options.ApiKey);
|
||||
|
||||
using var response = await _http.SendAsync(request, ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
_logger.LogWarning("Gemini returned {Status}: {Body}", (int)response.StatusCode, body);
|
||||
return new ExpenseAiSuggestion();
|
||||
}
|
||||
|
||||
// Navigate candidates[0].content.parts[0].text — the model's JSON answer as a string.
|
||||
using var doc = JsonDocument.Parse(await response.Content.ReadAsStreamAsync(ct));
|
||||
var text = doc.RootElement
|
||||
.GetProperty("candidates")[0]
|
||||
.GetProperty("content")
|
||||
.GetProperty("parts")[0]
|
||||
.GetProperty("text")
|
||||
.GetString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
_logger.LogWarning("Gemini response contained no text part.");
|
||||
return new ExpenseAiSuggestion();
|
||||
}
|
||||
|
||||
var parsed = JsonSerializer.Deserialize<GeminiAnswer>(
|
||||
text, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
if (parsed is null) return new ExpenseAiSuggestion();
|
||||
|
||||
var suggestion = new ExpenseAiSuggestion
|
||||
{
|
||||
EnglishDescription = string.IsNullOrWhiteSpace(parsed.EnglishDescription)
|
||||
? null
|
||||
: parsed.EnglishDescription.Trim(),
|
||||
Confidence = parsed.Confidence,
|
||||
};
|
||||
|
||||
// Re-validate the returned ids against the catalog; drop anything that doesn't line up.
|
||||
var group = groups.FirstOrDefault(candidate => candidate.Id == parsed.GroupId);
|
||||
if (group is not null)
|
||||
{
|
||||
suggestion.GroupId = group.Id;
|
||||
suggestion.GroupLabel = Label(group.Name_en, group.Name_zh);
|
||||
|
||||
var sub = group.Subs.FirstOrDefault(candidate => candidate.Id == parsed.SubCategoryId);
|
||||
if (sub is not null)
|
||||
{
|
||||
suggestion.SubCategoryId = sub.Id;
|
||||
suggestion.SubLabel = Label(sub.Name_en, sub.Name_zh);
|
||||
}
|
||||
}
|
||||
|
||||
return suggestion;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Expense AI assist failed.");
|
||||
return new ExpenseAiSuggestion();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Mirror the frontend's bilingual() convention: "English / 中文" (or just English).</summary>
|
||||
private static string Label(string nameEn, string? nameZh)
|
||||
=> string.IsNullOrWhiteSpace(nameZh) ? nameEn : $"{nameEn} / {nameZh}";
|
||||
|
||||
/// <summary>Shape of the model's JSON answer (constrained by responseSchema).</summary>
|
||||
private sealed class GeminiAnswer
|
||||
{
|
||||
public string? EnglishDescription { get; set; }
|
||||
public int GroupId { get; set; }
|
||||
public int SubCategoryId { get; set; }
|
||||
public double Confidence { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ROLAC.API.Services.Ai;
|
||||
|
||||
/// <summary>Google Gemini API settings (bound from the "Gemini" config section).</summary>
|
||||
public sealed class GeminiOptions
|
||||
{
|
||||
/// <summary>API key sent as the <c>X-goog-api-key</c> header. Keep out of source control.</summary>
|
||||
public string ApiKey { get; set; } = "";
|
||||
public string Model { get; set; } = "gemini-2.5-flash";
|
||||
public string BaseUrl { get; set; } = "https://generativelanguage.googleapis.com/v1beta";
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using ROLAC.API.DTOs.Expense;
|
||||
|
||||
namespace ROLAC.API.Services.Ai;
|
||||
|
||||
/// <summary>AI assistance for expense entry: translate a description and suggest a category.</summary>
|
||||
public interface IExpenseAiService
|
||||
{
|
||||
/// <summary>
|
||||
/// Translate <paramref name="chineseText"/> to concise accounting English and suggest the best
|
||||
/// major/sub category from the live catalog, using <paramref name="amount"/> as a hint.
|
||||
/// Never throws on an upstream/AI failure — returns a suggestion with null fields instead.
|
||||
/// </summary>
|
||||
Task<ExpenseAiSuggestion> SuggestAsync(string chineseText, decimal amount, CancellationToken ct = default);
|
||||
}
|
||||
@@ -41,5 +41,10 @@
|
||||
"Line": {
|
||||
"ChannelAccessToken": "",
|
||||
"ChannelSecret": ""
|
||||
},
|
||||
"Gemini": {
|
||||
"ApiKey": "",
|
||||
"Model": "gemini-flash-latest",
|
||||
"BaseUrl": "https://generativelanguage.googleapis.com/v1beta"
|
||||
}
|
||||
}
|
||||
|
||||
+30
-3
@@ -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>
|
||||
<!-- 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"
|
||||
|
||||
+47
-1
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user