mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
fix: keep the audit follow-ups inside the domain boundaries
The field-to-column map of shared place details moves to @trek/shared, where places and place-contrib both read it without depending on each other, and the assistant reads a recorded call's arguments with a helper of its own instead of reaching into the AI providers. The catch-up's country from the address is taken back out: it made place-contrib depend on atlas, which already depends on it, and it waits for a shared country lookup both can use.
This commit is contained in:
@@ -27,7 +27,6 @@ export interface PlaceTypedRow {
|
||||
name: string;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
address: string | null;
|
||||
osm_id: string | null;
|
||||
website: string | null;
|
||||
phone: string | null;
|
||||
@@ -306,7 +305,6 @@ export class PlaceContribOutboxRepository extends TrekRepository<PlaceContribOut
|
||||
'name',
|
||||
'lat',
|
||||
'lng',
|
||||
'address',
|
||||
'osm_id',
|
||||
'website',
|
||||
'phone',
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { AssistantToolCallRow } from '../../db/repositories/AssistantToolCa
|
||||
import { parseDbTimestamp, utcSuffix } from '../../db/types';
|
||||
import { decodeJson } from '../../utils/json-column';
|
||||
import type { AiChatMessage, AiProviderState, AiToolCall } from '../ai-connections/ai-chat-client.interface';
|
||||
import { asRecord, asText, parseToolArgs } from '../ai-connections/providers/provider-http';
|
||||
import { tourFileOutcome, tourFileReadNote, tourStopOutcome } from './assistant-history-tours';
|
||||
import { fitToSize } from './assistant-output';
|
||||
import {
|
||||
@@ -27,6 +26,19 @@ import {
|
||||
type AssistantToolApproval,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** A recorded call's arguments as an object: given as one, or as JSON text; anything else is none. */
|
||||
function recordOf(value: unknown): Record<string, unknown> {
|
||||
let parsed = value;
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* A thread's stored messages, read two ways: as the conversation the
|
||||
* provider is sent, and as the messages the client shows.
|
||||
@@ -58,11 +70,11 @@ function recordedCalls(state: AiProviderState | null): AiToolCall[] {
|
||||
state?.adapter === 'anthropic-messages'
|
||||
? state.blocks
|
||||
.filter((block) => block.type === 'tool_use')
|
||||
.map((block) => ({ id: block.id, name: block.name, args: asRecord(block.input) }))
|
||||
.map((block) => ({ id: block.id, name: block.name, args: recordOf(block.input) }))
|
||||
: state?.adapter === 'openai-responses'
|
||||
? state.items
|
||||
.filter((item) => item.type === 'function_call')
|
||||
.map((item) => ({ id: item.call_id, name: item.name, args: parseToolArgs(asText(item.arguments)) }))
|
||||
.map((item) => ({ id: item.call_id, name: item.name, args: recordOf(item.arguments) }))
|
||||
: [];
|
||||
return calls.flatMap(({ id, name, args }) =>
|
||||
typeof id === 'string' ? [{ id, name: typeof name === 'string' ? name : '', args }] : [],
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { PlaceContribOutbox } from '../../db/entities/PlaceContribOutbox.entity';
|
||||
import type { PlacesContributeBackfill } from '../../db/json-columns';
|
||||
import type { PlaceContribOutboxRepository, PlaceTypedRow } from '../../db/repositories/PlaceContribOutbox.repository';
|
||||
import { resolveCountryCodeSync } from '../atlas/atlas-geo';
|
||||
import { getCountryFromCoords } from '../../utils/country-borders';
|
||||
import { logInfo } from '../audit/audit-log.logger';
|
||||
import { inOrder } from '../common/in-order';
|
||||
import { valueHash } from '../common/value-hash';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { PlaceContribQueue } from './place-contrib-queue';
|
||||
import { PlaceContribSettings } from './place-contrib-settings';
|
||||
import { PLACE_CONTRIB_COLUMN, contribKeyOf } from './place-contrib.helpers';
|
||||
import { contribKeyOf } from './place-contrib.helpers';
|
||||
import type { ContribWrite } from './place-contrib.types';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { PlaceContribField } from '@trek/shared';
|
||||
import { PLACE_CONTRIB_COLUMN, type PlaceContribField } from '@trek/shared';
|
||||
|
||||
/** Places read per step of the catch-up. */
|
||||
const BACKFILL_PAGE = 100;
|
||||
@@ -104,8 +104,8 @@ function typedWrite(row: PlaceTypedRow, fields: readonly PlaceContribField[]): C
|
||||
name: place.name,
|
||||
lat: place.lat,
|
||||
lng: place.lng,
|
||||
// Resolved the way a save resolves it, so a place goes out with the same country either way.
|
||||
country: values.length > 0 ? resolveCountryCodeSync(place) : null,
|
||||
country:
|
||||
values.length > 0 && place.lat !== null && place.lng !== null ? getCountryFromCoords(place.lat, place.lng) : null,
|
||||
values,
|
||||
dropped: [],
|
||||
};
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
PLACES_PER_RUN,
|
||||
} from './place-contrib.constants';
|
||||
import {
|
||||
PLACE_CONTRIB_COLUMN,
|
||||
UPLOADED_IMAGE_PREFIX,
|
||||
baseHashOf,
|
||||
dayOf,
|
||||
@@ -37,6 +36,7 @@ import {
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
PLACE_CONTRIB_COLUMN,
|
||||
PLACE_CONTRIB_TERMS_VERSION,
|
||||
type PlaceContribField,
|
||||
type PlaceContribImageAnswer,
|
||||
|
||||
@@ -17,21 +17,6 @@ const OSM_ID = /^(node|way|relation):(\d{1,15})$/;
|
||||
/** Where the place pictures users upload live (the places domain's `placeImageUrl`). */
|
||||
export const UPLOADED_IMAGE_PREFIX = '/uploads/places/';
|
||||
|
||||
/**
|
||||
* The places column each field is stored in. The origin hash, the catch-up
|
||||
* and the sender all read a field through this one map: a value is shared
|
||||
* only while the column's hash matches the origin, so two copies drifting
|
||||
* apart would skip a field without a word.
|
||||
*/
|
||||
export const PLACE_CONTRIB_COLUMN = {
|
||||
website: 'website',
|
||||
phone: 'phone',
|
||||
email: 'email',
|
||||
opening_hours: 'opening_hours',
|
||||
description: 'description',
|
||||
image: 'image_url',
|
||||
} as const satisfies Record<PlaceContribField, string>;
|
||||
|
||||
/**
|
||||
* The key the service files a place under: `gers:<id>` for a place from the
|
||||
* TREK index, `osm:<type>/<id>` for an OpenStreetMap element. A place without
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { PLACE_DETAILS_CACHE_PAYLOAD } from '../../db/json-columns';
|
||||
import type { CachedProviderPayload, PlaceShareableRow } from '../../db/repositories/PlaceFieldOrigin.repository';
|
||||
import { decodeJson } from '../../utils/json-column';
|
||||
import { PLACE_CONTRIB_COLUMN } from '../place-contrib/place-contrib.helpers';
|
||||
import {
|
||||
PLACE_CONTRIB_COLUMN,
|
||||
PLACE_CONTRIB_FIELDS,
|
||||
normalizePlaceWebsite,
|
||||
type PlaceContribField,
|
||||
|
||||
@@ -115,15 +115,7 @@ async function agree(fields: PlaceContribFieldSwitches = ALL): Promise<void> {
|
||||
|
||||
/** A place with a website, whose origin is recorded as `origin` for the value given (the current one by default). */
|
||||
async function placeWith(
|
||||
opts: {
|
||||
osmId?: string | null;
|
||||
origin?: string;
|
||||
typed?: string;
|
||||
website?: string;
|
||||
lat?: number | null;
|
||||
lng?: number | null;
|
||||
address?: string;
|
||||
} = {},
|
||||
opts: { osmId?: string | null; origin?: string; typed?: string; website?: string } = {},
|
||||
): Promise<number> {
|
||||
const website = opts.website ?? 'https://cafe-luna.de';
|
||||
const { user } = await makeUser(orm());
|
||||
@@ -132,9 +124,8 @@ async function placeWith(
|
||||
name: 'Café Luna',
|
||||
osm_id: opts.osmId === undefined ? GERS : opts.osmId,
|
||||
website,
|
||||
lat: opts.lat === undefined ? 54.08 : opts.lat,
|
||||
lng: opts.lng === undefined ? 12.13 : opts.lng,
|
||||
address: opts.address ?? null,
|
||||
lat: 54.08,
|
||||
lng: 12.13,
|
||||
});
|
||||
await insertRow(orm(), PlaceFieldOrigin, {
|
||||
place: id,
|
||||
@@ -372,11 +363,4 @@ describe('PlaceContribBackfill', () => {
|
||||
afterId: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('PCB-018: the country comes from the address when the coordinates do not give one, as on a save', async () => {
|
||||
await placeWith({ lat: null, lng: null, address: 'Lange Straße 1, 18055 Rostock, Germany' });
|
||||
await agree();
|
||||
expect(await backfill().step(NOW)).toBe(1);
|
||||
expect((await findRows(orm(), PlaceContribOutbox, {})).map((row) => row.country)).toEqual(['DE']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,21 @@ export const PLACE_CONTRIB_FIELDS = ['website', 'phone', 'email', 'opening_hours
|
||||
export const placeContribFieldSchema = z.enum(PLACE_CONTRIB_FIELDS);
|
||||
export type PlaceContribField = z.infer<typeof placeContribFieldSchema>;
|
||||
|
||||
/**
|
||||
* The places column each field is stored in. The origin hash, the catch-up
|
||||
* and the sender all read a field through this one map: a value is shared
|
||||
* only while the column's hash matches the origin, so two copies drifting
|
||||
* apart would skip a field without a word.
|
||||
*/
|
||||
export const PLACE_CONTRIB_COLUMN = {
|
||||
website: 'website',
|
||||
phone: 'phone',
|
||||
email: 'email',
|
||||
opening_hours: 'opening_hours',
|
||||
description: 'description',
|
||||
image: 'image_url',
|
||||
} as const satisfies Record<PlaceContribField, string>;
|
||||
|
||||
/**
|
||||
* Where the current value of a field came from. Only `user` is ever shared, and
|
||||
* only while the stored hash still matches the value. The server decides
|
||||
|
||||
Reference in New Issue
Block a user