feat(translate): language detection, async HTTP LLM, history model, agent improvements

- Add async HTTP-based LLM transport (_llm_async_http.py)
- Add orthogonal LLM call tests
- Improve language detection (_lang_detect.py) and batch insert
- Update translate schemas, service utils, preview constants/prompts
- Add TranslateHistoryModel with pagination and filtering
- Update agent confirmation, persistence, langgraph setup, run, tools
- Improve LLM health checking in shared module
- Update translate runs API, history route
This commit is contained in:
2026-07-08 19:35:49 +03:00
parent 97a64ce056
commit c488a63dc0
30 changed files with 1148 additions and 550 deletions

View File

@@ -272,7 +272,7 @@ describe('fetchAllRuns', () => {
expect(mockApi.fetchApi).toHaveBeenCalledWith('/translate/runs');
});
it('fetches all runs with job_id, status, trigger_type, and created_by filters', async () => {
it('fetches all runs with job_id, status, trigger_type, created_by, and date filters', async () => {
mockApi.fetchApi.mockResolvedValue({ runs: [{ id: 'r1' }] });
const { fetchAllRuns } = await import('$lib/api/translate/runs.js');
const result = await fetchAllRuns({
@@ -280,12 +280,14 @@ describe('fetchAllRuns', () => {
status: 'FAILED',
trigger_type: 'manual',
created_by: 'user1',
date_from: '2026-07-01',
date_to: '2026-07-08',
page: 1,
page_size: 25,
});
expect(result).toEqual({ runs: [{ id: 'r1' }] });
expect(mockApi.fetchApi).toHaveBeenCalledWith(
'/translate/runs?page=1&page_size=25&job_id=job-1&status=FAILED&trigger_type=manual&created_by=user1',
'/translate/runs?page=1&page_size=25&job_id=job-1&status=FAILED&trigger_type=manual&created_by=user1&date_from=2026-07-01&date_to=2026-07-08',
);
});

View File

@@ -29,6 +29,8 @@ export interface AllRunsQueryOptions extends RunQueryOptions {
status?: string;
trigger_type?: string;
created_by?: string;
date_from?: string;
date_to?: string;
}
// #region triggerRun [C:2] [TYPE Function] [SEMANTICS translate, runs, trigger]
@@ -171,6 +173,8 @@ export async function fetchAllRuns<T = unknown>(options: AllRunsQueryOptions = {
if (options.status) params.append('status', options.status);
if (options.trigger_type) params.append('trigger_type', options.trigger_type);
if (options.created_by) params.append('created_by', options.created_by);
if (options.date_from) params.append('date_from', options.date_from);
if (options.date_to) params.append('date_to', options.date_to);
const query = params.toString();
return await api.fetchApi<T>(`/translate/runs${query ? `?${query}` : ''}`);
} catch (error) {

View File

@@ -54,7 +54,15 @@ export class TranslateHistoryModel {
this.isLoading = true;
this.uxState = 'loading';
try {
const result = await fetchAllRuns({ page: this.currentPage, page_size: this.pageSize, job_id: this.filterJobId || undefined, status: this.filterStatus || undefined, trigger_type: this.filterTrigger || undefined });
const result = await fetchAllRuns({
page: this.currentPage,
page_size: this.pageSize,
job_id: this.filterJobId || undefined,
status: this.filterStatus || undefined,
trigger_type: this.filterTrigger || undefined,
date_from: this.filterDateFrom || undefined,
date_to: this.filterDateTo || undefined,
});
this.runs = (result as { items?: Record<string, unknown>[] })?.items || [];
this.total = (result as { total?: number })?.total || 0;
this.uxState = this.runs.length === 0 ? 'empty' : 'populated';
@@ -72,8 +80,20 @@ export class TranslateHistoryModel {
async loadJobs(): Promise<void> {
try {
const result = await fetchJobs({ page_size: 100 });
this.jobs = Array.isArray(result) ? result : ((result as { items?: unknown[]; results?: unknown[] })?.items || (result as { items?: unknown[]; results?: unknown[] })?.results || []) as Record<string, unknown>[];
const pageSize = 100;
const first = await fetchJobs({ page: 1, page_size: pageSize });
const firstJobs = this._extractJobs(first);
const total = Array.isArray(first) ? firstJobs.length : ((first as { total?: number })?.total || firstJobs.length);
const jobs = [...firstJobs];
for (let page = 2; jobs.length < total; page += 1) {
const result = await fetchJobs({ page, page_size: pageSize });
const pageJobs = this._extractJobs(result);
if (pageJobs.length === 0) break;
jobs.push(...pageJobs);
}
this.jobs = jobs;
} catch { /* non-critical */ }
}
@@ -158,5 +178,11 @@ export class TranslateHistoryModel {
total_records: this.metrics.reduce((s: number, m: Record<string, unknown>) => s + ((m.total_records as number) || 0), 0),
};
}
private _extractJobs(result: unknown): Record<string, unknown>[] {
if (Array.isArray(result)) return result as Record<string, unknown>[];
const payload = result as { items?: unknown[]; results?: unknown[] };
return (payload?.items || payload?.results || []) as Record<string, unknown>[];
}
}
// #endregion Translate.HistoryModel

View File

@@ -86,10 +86,16 @@ describe("TranslateHistoryModel — Data Loading", () => {
// #region TranslateHistoryModel.LoadRuns [C:2] [TYPE Test]
it("loadRuns sets loading state and populates runs", async () => {
vi.mocked(fetchAllRuns).mockResolvedValue({ items: [{ id: "r1" }, { id: "r2" }], total: 2 });
model.filterDateFrom = "2026-07-01";
model.filterDateTo = "2026-07-08";
const promise = model.loadRuns();
expect(model.isLoading).toBe(true);
expect(model.uxState).toBe("loading");
await promise;
expect(fetchAllRuns).toHaveBeenCalledWith(expect.objectContaining({
date_from: "2026-07-01",
date_to: "2026-07-08",
}));
expect(model.runs).toHaveLength(2);
expect(model.total).toBe(2);
expect(model.uxState).toBe("populated");
@@ -138,6 +144,16 @@ describe("TranslateHistoryModel — Data Loading", () => {
expect(model.jobs).toHaveLength(1);
});
it("loadJobs fetches all pages when total exceeds first page", async () => {
vi.mocked(fetchJobs)
.mockResolvedValueOnce({ items: Array.from({ length: 100 }, (_, i) => ({ id: `j${i}` })), total: 101 })
.mockResolvedValueOnce({ items: [{ id: "j100", name: "Job 100" }], total: 101 });
await model.loadJobs();
expect(fetchJobs).toHaveBeenCalledWith({ page: 1, page_size: 100 });
expect(fetchJobs).toHaveBeenCalledWith({ page: 2, page_size: 100 });
expect(model.jobs).toHaveLength(101);
});
it("loadJobs accepts results format result", async () => {
vi.mocked(fetchJobs).mockResolvedValue({ results: [{ id: "j1", name: "Job 1" }] });
await model.loadJobs();

View File

@@ -53,7 +53,7 @@
<!-- Filters -->
<div class="bg-surface-card border border-border rounded-lg p-4 mb-4">
<div class="grid grid-cols-2 sm:grid-cols-5 gap-3">
<div class="grid grid-cols-2 sm:grid-cols-6 gap-3">
<select bind:value={m.filterJobId} class="px-3 py-2 border border-border-strong rounded-lg text-sm">
<option value="">{$t.translate?.history?.all_jobs}</option>
{#each m.jobs as job}<option value={job.id}>{job.name}</option>{/each}
@@ -73,6 +73,7 @@
<option value="baseline_expired">{$t.translate?.history?.trigger_baseline_expired}</option>
</select>
<input type="date" bind:value={m.filterDateFrom} class="px-3 py-2 border border-border-strong rounded-lg text-sm" placeholder={$t.translate?.common?.from} />
<input type="date" bind:value={m.filterDateTo} class="px-3 py-2 border border-border-strong rounded-lg text-sm" placeholder={$t.translate?.common?.to} />
<button onclick={() => { page0 = 0; m.applyFilters(); }} class="px-4 py-2 text-sm bg-primary text-white rounded-lg hover:bg-primary-hover">{$t.translate?.history?.filter}</button>
</div>
</div>
@@ -90,6 +91,11 @@
<!-- Populated / Detail -->
{:else if m.uxState === 'populated' || m.uxState === 'detail_open'}
<div class="mb-3 text-sm text-text-muted">
{_('translate.history.showing')
.replace('{count}', String(Math.min(m.currentPage * m.pageSize, m.total)))
.replace('{total}', String(m.total))}
</div>
<div class="space-y-2">
{#each m.runs as run}
<div onclick={() => m.openDetail(run)} class="bg-surface-card border border-border rounded-lg p-3 hover:shadow-sm hover:border-border-strong transition-all cursor-pointer">