mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 06:19:41 +02:00
feat(api): port remaining 12 modules to unified API server
Complete consolidation of all 15 app servers into one Hono/Bun process. Modules added: chat, context, picture, storage, todo, planta, nutriphi, guides, moodlit, news, traces, presi Total: 15 modules, one server, one port (3050), ~2400 LOC. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
eb97378438
commit
9363063cd7
14 changed files with 2014 additions and 0 deletions
304
apps/api/src/modules/traces/routes.ts
Normal file
304
apps/api/src/modules/traces/routes.ts
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
/**
|
||||
* Traces module — GPS sync + AI city guides
|
||||
* Ported from apps/traces/apps/server
|
||||
*
|
||||
* CRUD for locations, cities, places, POIs handled by mana-sync.
|
||||
* This module handles AI guide generation and location sync with city detection.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import {
|
||||
pgSchema,
|
||||
uuid,
|
||||
text,
|
||||
doublePrecision,
|
||||
timestamp,
|
||||
integer,
|
||||
pgEnum,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
// ─── DB Schema ──────────────────────────────────────────────
|
||||
|
||||
const DATABASE_URL =
|
||||
process.env.DATABASE_URL ?? 'postgresql://manacore:devpassword@localhost:5432/mana_platform';
|
||||
const LLM_URL = process.env.MANA_LLM_URL || 'http://localhost:3025';
|
||||
|
||||
const tracesSchema = pgSchema('traces');
|
||||
|
||||
const locationSourceEnum = pgEnum('location_source', [
|
||||
'foreground',
|
||||
'background',
|
||||
'manual',
|
||||
'photo-import',
|
||||
]);
|
||||
|
||||
const guideStatusEnum = pgEnum('guide_status', ['generating', 'ready', 'error']);
|
||||
|
||||
const poiCategoryEnum = pgEnum('poi_category', [
|
||||
'building',
|
||||
'monument',
|
||||
'church',
|
||||
'museum',
|
||||
'palace',
|
||||
'bridge',
|
||||
'park',
|
||||
'square',
|
||||
'sculpture',
|
||||
'fountain',
|
||||
'historic_site',
|
||||
'other',
|
||||
]);
|
||||
|
||||
const locations = tracesSchema.table('locations', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: text('user_id').notNull(),
|
||||
latitude: doublePrecision('latitude').notNull(),
|
||||
longitude: doublePrecision('longitude').notNull(),
|
||||
recordedAt: timestamp('recorded_at', { withTimezone: true }).notNull(),
|
||||
accuracy: doublePrecision('accuracy'),
|
||||
altitude: doublePrecision('altitude'),
|
||||
speed: doublePrecision('speed'),
|
||||
source: locationSourceEnum('source').default('foreground'),
|
||||
addressFormatted: text('address_formatted'),
|
||||
city: text('city'),
|
||||
country: text('country'),
|
||||
countryCode: text('country_code'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
const cities = tracesSchema.table('cities', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
country: text('country').notNull(),
|
||||
countryCode: text('country_code').notNull(),
|
||||
latitude: doublePrecision('latitude').notNull(),
|
||||
longitude: doublePrecision('longitude').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
const pois = tracesSchema.table('pois', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
latitude: doublePrecision('latitude').notNull(),
|
||||
longitude: doublePrecision('longitude').notNull(),
|
||||
category: poiCategoryEnum('category').default('other').notNull(),
|
||||
cityId: uuid('city_id').notNull(),
|
||||
aiSummary: text('ai_summary'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
const guides = tracesSchema.table('guides', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: text('user_id').notNull(),
|
||||
cityId: uuid('city_id').notNull(),
|
||||
title: text('title').notNull(),
|
||||
description: text('description'),
|
||||
status: guideStatusEnum('status').default('generating').notNull(),
|
||||
estimatedDurationMin: integer('estimated_duration_min'),
|
||||
language: text('language').default('de').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
const guidePois = tracesSchema.table('guide_pois', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
guideId: uuid('guide_id').notNull(),
|
||||
poiId: uuid('poi_id').notNull(),
|
||||
sortOrder: integer('sort_order').notNull(),
|
||||
aiNarrative: text('ai_narrative'),
|
||||
narrativeLanguage: text('narrative_language').default('de'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
const connection = postgres(DATABASE_URL, { max: 5, idle_timeout: 20 });
|
||||
const db = drizzle(connection, { schema: { locations, cities, pois, guides, guidePois } });
|
||||
|
||||
// ─── Routes ─────────────────────────────────────────────────
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
// ─── Guide Generation (server-only: AI + search) ────────────
|
||||
|
||||
routes.post('/guides/generate', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const params = await c.req.json<{
|
||||
cityId: string;
|
||||
title: string;
|
||||
language?: string;
|
||||
maxPois?: number;
|
||||
}>();
|
||||
|
||||
// Get city
|
||||
const [city] = await db.select().from(cities).where(eq(cities.id, params.cityId)).limit(1);
|
||||
if (!city) return c.json({ error: 'City not found' }, 404);
|
||||
|
||||
// Create guide in 'generating' state
|
||||
const [guide] = await db
|
||||
.insert(guides)
|
||||
.values({
|
||||
userId,
|
||||
cityId: params.cityId,
|
||||
title: params.title || `Guide: ${city.name}`,
|
||||
status: 'generating',
|
||||
language: params.language || 'de',
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Fire-and-forget async pipeline
|
||||
runGuidePipeline(guide.id, userId, city, params.language || 'de', params.maxPois || 10).catch(
|
||||
(err) => {
|
||||
console.error('Guide generation failed:', err);
|
||||
db.update(guides)
|
||||
.set({ status: 'error' })
|
||||
.where(eq(guides.id, guide.id))
|
||||
.catch(() => {});
|
||||
}
|
||||
);
|
||||
|
||||
return c.json(guide, 201);
|
||||
});
|
||||
|
||||
routes.get('/guides', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
return c.json(await db.select().from(guides).where(eq(guides.userId, userId)));
|
||||
});
|
||||
|
||||
routes.get('/guides/:id', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const guideId = c.req.param('id');
|
||||
|
||||
const [guide] = await db
|
||||
.select()
|
||||
.from(guides)
|
||||
.where(and(eq(guides.id, guideId), eq(guides.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!guide) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
const waypoints = await db
|
||||
.select()
|
||||
.from(guidePois)
|
||||
.innerJoin(pois, eq(guidePois.poiId, pois.id))
|
||||
.where(eq(guidePois.guideId, guideId))
|
||||
.orderBy(guidePois.sortOrder);
|
||||
|
||||
return c.json({ ...guide, waypoints });
|
||||
});
|
||||
|
||||
routes.delete('/guides/:id', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
await db.delete(guides).where(and(eq(guides.id, c.req.param('id')), eq(guides.userId, userId)));
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// ─── Location Sync (server-only: city detection) ────────────
|
||||
|
||||
routes.post('/locations/sync', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const { items } = await c.req.json();
|
||||
|
||||
let synced = 0;
|
||||
for (const item of items || []) {
|
||||
try {
|
||||
await db
|
||||
.insert(locations)
|
||||
.values({
|
||||
userId,
|
||||
latitude: item.latitude,
|
||||
longitude: item.longitude,
|
||||
recordedAt: new Date(item.recordedAt),
|
||||
accuracy: item.accuracy,
|
||||
altitude: item.altitude,
|
||||
speed: item.speed,
|
||||
source: item.source || 'foreground',
|
||||
addressFormatted: item.address,
|
||||
city: item.city,
|
||||
country: item.country,
|
||||
countryCode: item.countryCode,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
synced++;
|
||||
} catch {
|
||||
// Skip duplicates
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ synced, total: items?.length || 0 });
|
||||
});
|
||||
|
||||
// ─── Internal: Guide Pipeline ───────────────────────────────
|
||||
|
||||
async function runGuidePipeline(
|
||||
guideId: string,
|
||||
userId: string,
|
||||
city: { id: string; name: string },
|
||||
language: string,
|
||||
maxPois: number
|
||||
) {
|
||||
// 1. Find nearby POIs
|
||||
const nearbyPois = await db.select().from(pois).where(eq(pois.cityId, city.id)).limit(maxPois);
|
||||
|
||||
if (nearbyPois.length === 0) {
|
||||
await db.update(guides).set({ status: 'ready' }).where(eq(guides.id, guideId));
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Generate AI narratives for each POI
|
||||
for (let i = 0; i < nearbyPois.length; i++) {
|
||||
const poi = nearbyPois[i];
|
||||
let narrative = poi.aiSummary || '';
|
||||
|
||||
if (!narrative) {
|
||||
try {
|
||||
const res = await fetch(`${LLM_URL}/api/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `Du bist ein Stadtführer in ${city.name}. Schreibe einen kurzen, informativen Text (max 200 Wörter) über die Sehenswürdigkeit. Sprache: ${language === 'de' ? 'Deutsch' : 'English'}.`,
|
||||
},
|
||||
{ role: 'user', content: `Erzähle mir über: ${poi.name}` },
|
||||
],
|
||||
model: 'gemma3:4b',
|
||||
max_tokens: 300,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
narrative = data.choices?.[0]?.message?.content?.trim() || poi.name;
|
||||
} else {
|
||||
narrative = poi.description || poi.name;
|
||||
}
|
||||
} catch {
|
||||
narrative = poi.description || poi.name;
|
||||
}
|
||||
}
|
||||
|
||||
await db.insert(guidePois).values({
|
||||
guideId,
|
||||
poiId: poi.id,
|
||||
sortOrder: i,
|
||||
aiNarrative: narrative,
|
||||
narrativeLanguage: language,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Mark as ready
|
||||
await db
|
||||
.update(guides)
|
||||
.set({
|
||||
status: 'ready',
|
||||
estimatedDurationMin: nearbyPois.length * 15,
|
||||
})
|
||||
.where(eq(guides.id, guideId));
|
||||
}
|
||||
|
||||
export { routes as tracesRoutes };
|
||||
Loading…
Add table
Add a link
Reference in a new issue