Compare commits
23 Commits
21e9f8703c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07ffd95a02 | ||
|
|
842d151a44 | ||
|
|
9642bffd16 | ||
|
|
f59a730169 | ||
|
|
53a7cb7cc8 | ||
|
|
f6caaac28f | ||
|
|
a8831e5581 | ||
|
|
d18802304a | ||
|
|
4f6cde166e | ||
|
|
ddb276b3f1 | ||
|
|
12f9562dbc | ||
|
|
f781845ec7 | ||
|
|
2816252e6c | ||
|
|
5b9b2a71d7 | ||
|
|
b231eb6a27 | ||
|
|
a6203bde71 | ||
|
|
2d305aec98 | ||
|
|
dc26274cf8 | ||
|
|
97b00e08fb | ||
|
|
0997ef7f91 | ||
|
|
a4f9b5b526 | ||
|
|
eeec5e3f8a | ||
|
|
ada03ac070 |
60
.gitea/workflows/build-and-deploy.yml
Normal file
60
.gitea/workflows/build-and-deploy.yml
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
name: Build and deploy Worker
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: builder
|
||||||
|
env:
|
||||||
|
SERVICE_NAME: worker
|
||||||
|
IMAGE_SHA: gitea.dsrptlab.com/mapflow/worker:${{ github.sha }}
|
||||||
|
IMAGE_LATEST: gitea.dsrptlab.com/mapflow/worker:latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Configure Gitea registry auth
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p ~/.docker
|
||||||
|
auth="$(printf '%s:%s' "${{ secrets.REGISTRY_USERNAME }}" "${{ secrets.REGISTRY_TOKEN }}" | base64 | tr -d '\n')"
|
||||||
|
printf '{"auths":{"gitea.dsrptlab.com":{"auth":"%s"}}}\n' "$auth" > ~/.docker/config.json
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if docker buildx build --push --provenance=false --tag "$IMAGE_SHA" --tag "$IMAGE_LATEST" .; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep "$((attempt * 10))"
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Skip stale deployment
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
latest_sha="$(git ls-remote origin refs/heads/main | awk '{print $1}')"
|
||||||
|
if [ "$latest_sha" = "${GITHUB_SHA}" ]; then
|
||||||
|
touch .deploy-current
|
||||||
|
else
|
||||||
|
echo "A newer main commit exists: $latest_sha. Skipping deploy for ${GITHUB_SHA}."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Trigger Dokploy deploy webhook
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
[ -f .deploy-current ] || exit 0
|
||||||
|
payload=$(cat <<JSON
|
||||||
|
{"ref":"refs/heads/main","after":"$GITHUB_SHA","commits":[{"id":"$GITHUB_SHA","message":"$SERVICE_NAME #${GITHUB_RUN_NUMBER:-0} ${GITHUB_SHA:0:7}","modified":["Dockerfile"]}]}
|
||||||
|
JSON
|
||||||
|
)
|
||||||
|
response_file="$(mktemp)"
|
||||||
|
status_code="$(curl -sS -o "$response_file" -w "%{http_code}" -X POST "${{ secrets.DOKPLOY_DEPLOY_WEBHOOK }}" \
|
||||||
|
-H "x-gitea-event: push" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$payload")"
|
||||||
|
cat "$response_file"
|
||||||
|
[ "$status_code" = "200" ]
|
||||||
@@ -5,10 +5,9 @@ RUN npm ci
|
|||||||
|
|
||||||
FROM node:22-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV DATABASE_URL="postgresql://mapflow:mapflow@localhost:5432/mapflow"
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run prisma:generate
|
RUN DATABASE_URL="postgresql://mapflow:mapflow@localhost:5432/mapflow" npm run prisma:generate
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:22-alpine AS runner
|
FROM node:22-alpine AS runner
|
||||||
@@ -17,7 +16,7 @@ ENV NODE_ENV=production
|
|||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
COPY --from=build /app/dist ./dist
|
COPY --from=build /app/dist ./dist
|
||||||
COPY --from=build /app/src/generated ./src/generated
|
COPY --from=build /app/src/generated ./dist/generated
|
||||||
COPY --from=build /app/prisma ./prisma
|
COPY --from=build /app/prisma ./prisma
|
||||||
COPY --from=build /app/prisma.config.ts ./prisma.config.ts
|
COPY --from=build /app/prisma.config.ts ./prisma.config.ts
|
||||||
CMD ["node", "dist/hatchet/worker.js"]
|
CMD ["node", "dist/hatchet/worker.js"]
|
||||||
|
|||||||
@@ -22,17 +22,38 @@ model Place {
|
|||||||
name String
|
name String
|
||||||
latitude Float
|
latitude Float
|
||||||
longitude Float
|
longitude Float
|
||||||
|
googlePrimaryType String?
|
||||||
|
googleTypes String[] @default([])
|
||||||
experiences VoiceExperience[]
|
experiences VoiceExperience[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
telegramId String @unique
|
||||||
|
username String?
|
||||||
|
firstName String?
|
||||||
|
lastName String?
|
||||||
|
photoUrl String?
|
||||||
|
languageCode String?
|
||||||
|
isAdmin Boolean @default(false)
|
||||||
|
voiceExperiences VoiceExperience[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
model VoiceExperience {
|
model VoiceExperience {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
placeId String
|
placeId String
|
||||||
place Place @relation(fields: [placeId], references: [id])
|
place Place @relation(fields: [placeId], references: [id])
|
||||||
|
userId String?
|
||||||
|
user User? @relation(fields: [userId], references: [id])
|
||||||
durationSeconds Int
|
durationSeconds Int
|
||||||
audioObjectKey String
|
audioObjectKey String
|
||||||
|
audioContentBase64 String?
|
||||||
|
audioMimeType String?
|
||||||
|
audioAccessToken String? @unique
|
||||||
status VoiceExperienceStatus @default(UPLOADED)
|
status VoiceExperienceStatus @default(UPLOADED)
|
||||||
transcript String?
|
transcript String?
|
||||||
analysis Json?
|
analysis Json?
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
import { loadVaultEnvironment } from './vault/env.js';
|
||||||
|
|
||||||
|
await loadVaultEnvironment();
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
databaseUrl: process.env.DATABASE_URL ?? '',
|
databaseUrl: process.env.DATABASE_URL ?? '',
|
||||||
workerName: process.env.HATCHET_WORKER_NAME ?? 'mapflow-hatchet-worker',
|
workerName: process.env.HATCHET_WORKER_NAME ?? 'mapflow-hatchet-worker',
|
||||||
workerSlots: Number.parseInt(process.env.HATCHET_WORKER_SLOTS ?? '4', 10),
|
workerSlots: Number.parseInt(process.env.HATCHET_WORKER_SLOTS ?? '4', 10),
|
||||||
|
publicApiUrl: process.env.PUBLIC_API_URL ?? 'https://api.map.craftee.vn',
|
||||||
|
deepgramApiKey: process.env.DEEPGRAM_API_KEY ?? '',
|
||||||
|
deepgramModel: process.env.DEEPGRAM_MODEL ?? 'nova-3',
|
||||||
|
deepgramLanguage: process.env.DEEPGRAM_LANGUAGE ?? 'ru',
|
||||||
|
openRouterApiKey: process.env.OPENROUTER_API_KEY ?? '',
|
||||||
|
openRouterModel:
|
||||||
|
process.env.OPENROUTER_MODEL ?? 'deepseek/deepseek-v4-flash',
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -126,6 +126,21 @@ exports.Prisma.PlaceScalarFieldEnum = {
|
|||||||
name: 'name',
|
name: 'name',
|
||||||
latitude: 'latitude',
|
latitude: 'latitude',
|
||||||
longitude: 'longitude',
|
longitude: 'longitude',
|
||||||
|
googlePrimaryType: 'googlePrimaryType',
|
||||||
|
googleTypes: 'googleTypes',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.UserScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
telegramId: 'telegramId',
|
||||||
|
username: 'username',
|
||||||
|
firstName: 'firstName',
|
||||||
|
lastName: 'lastName',
|
||||||
|
photoUrl: 'photoUrl',
|
||||||
|
languageCode: 'languageCode',
|
||||||
|
isAdmin: 'isAdmin',
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
};
|
};
|
||||||
@@ -133,8 +148,12 @@ exports.Prisma.PlaceScalarFieldEnum = {
|
|||||||
exports.Prisma.VoiceExperienceScalarFieldEnum = {
|
exports.Prisma.VoiceExperienceScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
placeId: 'placeId',
|
placeId: 'placeId',
|
||||||
|
userId: 'userId',
|
||||||
durationSeconds: 'durationSeconds',
|
durationSeconds: 'durationSeconds',
|
||||||
audioObjectKey: 'audioObjectKey',
|
audioObjectKey: 'audioObjectKey',
|
||||||
|
audioContentBase64: 'audioContentBase64',
|
||||||
|
audioMimeType: 'audioMimeType',
|
||||||
|
audioAccessToken: 'audioAccessToken',
|
||||||
status: 'status',
|
status: 'status',
|
||||||
transcript: 'transcript',
|
transcript: 'transcript',
|
||||||
analysis: 'analysis',
|
analysis: 'analysis',
|
||||||
@@ -157,16 +176,16 @@ exports.Prisma.QueryMode = {
|
|||||||
insensitive: 'insensitive'
|
insensitive: 'insensitive'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.Prisma.NullsOrder = {
|
||||||
|
first: 'first',
|
||||||
|
last: 'last'
|
||||||
|
};
|
||||||
|
|
||||||
exports.Prisma.JsonNullValueFilter = {
|
exports.Prisma.JsonNullValueFilter = {
|
||||||
DbNull: Prisma.DbNull,
|
DbNull: Prisma.DbNull,
|
||||||
JsonNull: Prisma.JsonNull,
|
JsonNull: Prisma.JsonNull,
|
||||||
AnyNull: Prisma.AnyNull
|
AnyNull: Prisma.AnyNull
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.Prisma.NullsOrder = {
|
|
||||||
first: 'first',
|
|
||||||
last: 'last'
|
|
||||||
};
|
|
||||||
exports.VoiceExperienceStatus = exports.$Enums.VoiceExperienceStatus = {
|
exports.VoiceExperienceStatus = exports.$Enums.VoiceExperienceStatus = {
|
||||||
UPLOADED: 'UPLOADED',
|
UPLOADED: 'UPLOADED',
|
||||||
TRANSCRIBING: 'TRANSCRIBING',
|
TRANSCRIBING: 'TRANSCRIBING',
|
||||||
@@ -178,6 +197,7 @@ exports.VoiceExperienceStatus = exports.$Enums.VoiceExperienceStatus = {
|
|||||||
|
|
||||||
exports.Prisma.ModelName = {
|
exports.Prisma.ModelName = {
|
||||||
Place: 'Place',
|
Place: 'Place',
|
||||||
|
User: 'User',
|
||||||
VoiceExperience: 'VoiceExperience'
|
VoiceExperience: 'VoiceExperience'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
2252
src/generated/prisma/index.d.ts
vendored
2252
src/generated/prisma/index.d.ts
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "prisma-client-5316d8e66293a4954be6e26169692366997bf25fe9186e98c87e62eed3dc691e",
|
"name": "prisma-client-404088dc715856cb4ed2e7f2d4b8eb35c87427ce32c0e67bc04d6ff67961fc2b",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"types": "index.d.ts",
|
"types": "index.d.ts",
|
||||||
"browser": "default.js",
|
"browser": "default.js",
|
||||||
|
|||||||
@@ -22,17 +22,38 @@ model Place {
|
|||||||
name String
|
name String
|
||||||
latitude Float
|
latitude Float
|
||||||
longitude Float
|
longitude Float
|
||||||
|
googlePrimaryType String?
|
||||||
|
googleTypes String[] @default([])
|
||||||
experiences VoiceExperience[]
|
experiences VoiceExperience[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
telegramId String @unique
|
||||||
|
username String?
|
||||||
|
firstName String?
|
||||||
|
lastName String?
|
||||||
|
photoUrl String?
|
||||||
|
languageCode String?
|
||||||
|
isAdmin Boolean @default(false)
|
||||||
|
voiceExperiences VoiceExperience[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
model VoiceExperience {
|
model VoiceExperience {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
placeId String
|
placeId String
|
||||||
place Place @relation(fields: [placeId], references: [id])
|
place Place @relation(fields: [placeId], references: [id])
|
||||||
|
userId String?
|
||||||
|
user User? @relation(fields: [userId], references: [id])
|
||||||
durationSeconds Int
|
durationSeconds Int
|
||||||
audioObjectKey String
|
audioObjectKey String
|
||||||
|
audioContentBase64 String?
|
||||||
|
audioMimeType String?
|
||||||
|
audioAccessToken String? @unique
|
||||||
status VoiceExperienceStatus @default(UPLOADED)
|
status VoiceExperienceStatus @default(UPLOADED)
|
||||||
transcript String?
|
transcript String?
|
||||||
analysis Json?
|
analysis Json?
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
|
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { hatchet } from './hatchet-client.js';
|
|
||||||
import { processVoiceExperienceWorkflow } from './workflows/process-voice-experience.js';
|
|
||||||
|
|
||||||
function resolveWorkerSlots(): number {
|
function resolveWorkerSlots(): number {
|
||||||
if (!Number.isFinite(config.workerSlots) || config.workerSlots <= 0) return 4;
|
if (!Number.isFinite(config.workerSlots) || config.workerSlots <= 0) return 4;
|
||||||
@@ -14,6 +12,10 @@ async function main() {
|
|||||||
throw new Error('HATCHET_CLIENT_TOKEN is required for hatchet worker');
|
throw new Error('HATCHET_CLIENT_TOKEN is required for hatchet worker');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { hatchet } = await import('./hatchet-client.js');
|
||||||
|
const { processVoiceExperienceWorkflow } = await import(
|
||||||
|
'./workflows/process-voice-experience.js'
|
||||||
|
);
|
||||||
const worker = await hatchet.worker(config.workerName, {
|
const worker = await hatchet.worker(config.workerName, {
|
||||||
workflows: [processVoiceExperienceWorkflow],
|
workflows: [processVoiceExperienceWorkflow],
|
||||||
slots: resolveWorkerSlots(),
|
slots: resolveWorkerSlots(),
|
||||||
|
|||||||
@@ -1,6 +1,31 @@
|
|||||||
import { buildPlaceAnalysis } from '../ontology/place-ontology.js';
|
import { config } from '../config.js';
|
||||||
|
import { placeOntology } from '../ontology/place-ontology.js';
|
||||||
import { prisma } from '../prisma.js';
|
import { prisma } from '../prisma.js';
|
||||||
|
|
||||||
|
type OpenRouterResponse = {
|
||||||
|
choices?: Array<{
|
||||||
|
finish_reason?: string;
|
||||||
|
message?: {
|
||||||
|
content?: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
error?: {
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type PlaceAnalysis = {
|
||||||
|
placeName: string;
|
||||||
|
tags: string[];
|
||||||
|
signals: Array<{
|
||||||
|
axis: string;
|
||||||
|
leaf: string;
|
||||||
|
evidence: string;
|
||||||
|
confidence: number;
|
||||||
|
}>;
|
||||||
|
summary: string;
|
||||||
|
};
|
||||||
|
|
||||||
export async function analyzeVoiceExperience(experienceId: string) {
|
export async function analyzeVoiceExperience(experienceId: string) {
|
||||||
const experience = await prisma.voiceExperience.findUnique({
|
const experience = await prisma.voiceExperience.findUnique({
|
||||||
where: { id: experienceId },
|
where: { id: experienceId },
|
||||||
@@ -20,7 +45,7 @@ export async function analyzeVoiceExperience(experienceId: string) {
|
|||||||
data: { status: 'ANALYZING' },
|
data: { status: 'ANALYZING' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const analysis = buildPlaceAnalysis({
|
const analysis = await buildPlaceAnalysis({
|
||||||
placeName: experience.place.name,
|
placeName: experience.place.name,
|
||||||
transcript: experience.transcript,
|
transcript: experience.transcript,
|
||||||
});
|
});
|
||||||
@@ -35,3 +60,136 @@ export async function analyzeVoiceExperience(experienceId: string) {
|
|||||||
|
|
||||||
return { tags: analysis.tags };
|
return { tags: analysis.tags };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function buildPlaceAnalysis(input: {
|
||||||
|
placeName: string;
|
||||||
|
transcript: string;
|
||||||
|
}): Promise<PlaceAnalysis> {
|
||||||
|
if (!config.openRouterApiKey) {
|
||||||
|
throw new Error('OPENROUTER_API_KEY is required for voice analysis.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ontology = placeOntology.map((axis) => ({
|
||||||
|
axis: axis.id,
|
||||||
|
options: axis.leaves.map((leaf) => ({
|
||||||
|
tag: `${axis.id}:${leaf.id}`,
|
||||||
|
keywords: leaf.keywords,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${config.openRouterApiKey}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'http-referer': 'https://map.craftee.vn',
|
||||||
|
'x-title': 'MapFlow',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: config.openRouterModel,
|
||||||
|
provider: { require_parameters: true },
|
||||||
|
temperature: 0.1,
|
||||||
|
max_tokens: 2000,
|
||||||
|
response_format: {
|
||||||
|
type: 'json_schema',
|
||||||
|
json_schema: {
|
||||||
|
name: 'place_analysis',
|
||||||
|
strict: true,
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['placeName', 'tags', 'signals', 'summary'],
|
||||||
|
properties: {
|
||||||
|
placeName: { type: 'string' },
|
||||||
|
tags: { type: 'array', items: { type: 'string' } },
|
||||||
|
signals: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['axis', 'leaf', 'evidence', 'confidence'],
|
||||||
|
properties: {
|
||||||
|
axis: { type: 'string' },
|
||||||
|
leaf: { type: 'string' },
|
||||||
|
evidence: { type: 'string' },
|
||||||
|
confidence: { type: 'number', minimum: 0, maximum: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
summary: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content:
|
||||||
|
'You classify voice reviews about places. Return one valid JSON object in message.content. Do not return markdown. Use only allowed ontology tags.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: JSON.stringify({
|
||||||
|
task:
|
||||||
|
'Analyze the transcript and select place-experience tags that are explicitly supported by the text. Do not invent facts.',
|
||||||
|
outputShape: {
|
||||||
|
placeName: 'string',
|
||||||
|
tags: ['axis:leaf'],
|
||||||
|
signals: [
|
||||||
|
{
|
||||||
|
axis: 'string',
|
||||||
|
leaf: 'string',
|
||||||
|
evidence: 'short quote or paraphrase from transcript',
|
||||||
|
confidence: 'number from 0 to 1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
summary: 'one short sentence',
|
||||||
|
},
|
||||||
|
placeName: input.placeName,
|
||||||
|
ontology,
|
||||||
|
transcript: input.transcript,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorBody = await response.text();
|
||||||
|
throw new Error(
|
||||||
|
`OpenRouter analysis failed with ${response.status}: ${errorBody.slice(0, 500)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as OpenRouterResponse;
|
||||||
|
const choice = payload.choices?.[0];
|
||||||
|
const content = choice?.message?.content?.trim();
|
||||||
|
if (!content) {
|
||||||
|
throw new Error(
|
||||||
|
`OpenRouter returned an empty analysis for ${config.openRouterModel}; finish_reason=${choice?.finish_reason ?? 'missing'}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const analysis = JSON.parse(content) as PlaceAnalysis;
|
||||||
|
assertValidAnalysis(analysis);
|
||||||
|
return analysis;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertValidAnalysis(analysis: PlaceAnalysis) {
|
||||||
|
const allowedTags = new Set(
|
||||||
|
placeOntology.flatMap((axis) =>
|
||||||
|
axis.leaves.map((leaf) => `${axis.id}:${leaf.id}`),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!Array.isArray(analysis.tags)) {
|
||||||
|
throw new Error('OpenRouter analysis tags must be an array.');
|
||||||
|
}
|
||||||
|
for (const tag of analysis.tags) {
|
||||||
|
if (!allowedTags.has(tag)) {
|
||||||
|
throw new Error(`OpenRouter returned unsupported tag: ${tag}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Array.isArray(analysis.signals)) {
|
||||||
|
throw new Error('OpenRouter analysis signals must be an array.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
|
import { config } from '../config.js';
|
||||||
import { prisma } from '../prisma.js';
|
import { prisma } from '../prisma.js';
|
||||||
|
|
||||||
|
type DeepgramResponse = {
|
||||||
|
results?: {
|
||||||
|
channels?: Array<{
|
||||||
|
alternatives?: Array<{
|
||||||
|
transcript?: string;
|
||||||
|
paragraphs?: {
|
||||||
|
transcript?: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export async function transcribeVoiceExperience(experienceId: string) {
|
export async function transcribeVoiceExperience(experienceId: string) {
|
||||||
const experience = await prisma.voiceExperience.findUnique({
|
const experience = await prisma.voiceExperience.findUnique({
|
||||||
where: { id: experienceId },
|
where: { id: experienceId },
|
||||||
@@ -14,11 +28,13 @@ export async function transcribeVoiceExperience(experienceId: string) {
|
|||||||
data: { status: 'TRANSCRIBING' },
|
data: { status: 'TRANSCRIBING' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const transcript = await transcribeAudioObject(experience.audioObjectKey);
|
const transcript = await transcribeAudioObject(experience.id);
|
||||||
|
|
||||||
await prisma.voiceExperience.update({
|
await prisma.voiceExperience.update({
|
||||||
where: { id: experienceId },
|
where: { id: experienceId },
|
||||||
data: {
|
data: {
|
||||||
|
audioContentBase64: null,
|
||||||
|
audioAccessToken: null,
|
||||||
transcript,
|
transcript,
|
||||||
status: 'TRANSCRIBED',
|
status: 'TRANSCRIBED',
|
||||||
},
|
},
|
||||||
@@ -27,7 +43,68 @@ export async function transcribeVoiceExperience(experienceId: string) {
|
|||||||
return { transcript };
|
return { transcript };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function transcribeAudioObject(audioObjectKey: string): Promise<string> {
|
async function transcribeAudioObject(experienceId: string): Promise<string> {
|
||||||
// TODO: replace this adapter with the production speech-to-text provider.
|
if (!config.deepgramApiKey) {
|
||||||
return `Transcription placeholder for ${audioObjectKey}`;
|
throw new Error('DEEPGRAM_API_KEY is required for transcription.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const experience = await prisma.voiceExperience.findUnique({
|
||||||
|
where: { id: experienceId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
audioObjectKey: true,
|
||||||
|
audioAccessToken: true,
|
||||||
|
audioContentBase64: true,
|
||||||
|
audioMimeType: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!experience) {
|
||||||
|
throw new Error(`Voice experience ${experienceId} was not found.`);
|
||||||
|
}
|
||||||
|
if (!experience.audioAccessToken) {
|
||||||
|
throw new Error(
|
||||||
|
`Voice audio ${experience.audioObjectKey} has no access token.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!experience.audioContentBase64 || !experience.audioMimeType) {
|
||||||
|
throw new Error(
|
||||||
|
`Voice audio ${experience.audioObjectKey} has no stored content.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
model: config.deepgramModel,
|
||||||
|
language: config.deepgramLanguage,
|
||||||
|
smart_format: 'true',
|
||||||
|
punctuate: 'true',
|
||||||
|
});
|
||||||
|
const audioUrl = `${config.publicApiUrl.replace(/\/$/, '')}/audio/voice-experiences/${experience.id}?token=${encodeURIComponent(experience.audioAccessToken)}`;
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.deepgram.com/v1/listen?${params.toString()}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
authorization: `Token ${config.deepgramApiKey}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ url: audioUrl }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Deepgram transcription failed with ${response.status}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as DeepgramResponse;
|
||||||
|
const alternative = payload.results?.channels?.[0]?.alternatives?.[0];
|
||||||
|
const transcript =
|
||||||
|
alternative?.paragraphs?.transcript?.trim() ??
|
||||||
|
alternative?.transcript?.trim() ??
|
||||||
|
'';
|
||||||
|
if (!transcript) {
|
||||||
|
throw new Error('Deepgram returned an empty transcript.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return transcript;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const placeOntology: OntologyAxis[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'intent',
|
id: 'function',
|
||||||
leaves: [
|
leaves: [
|
||||||
{ id: 'reset', keywords: ['rest', 'breathe', 'reset', 'recover'] },
|
{ id: 'reset', keywords: ['rest', 'breathe', 'reset', 'recover'] },
|
||||||
{ id: 'impress', keywords: ['impress', 'beautiful', 'special', 'wow'] },
|
{ id: 'impress', keywords: ['impress', 'beautiful', 'special', 'wow'] },
|
||||||
|
|||||||
65
src/vault/env.ts
Normal file
65
src/vault/env.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
type VaultConfig = {
|
||||||
|
address: string;
|
||||||
|
token: string;
|
||||||
|
mount: string;
|
||||||
|
sharedPath: string;
|
||||||
|
projectPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function requireEnv(name: string) {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`${name} is required when VAULT_ENABLED=true.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function vaultConfig(): VaultConfig {
|
||||||
|
return {
|
||||||
|
address: requireEnv('VAULT_ADDR').replace(/\/$/, ''),
|
||||||
|
token: requireEnv('VAULT_TOKEN'),
|
||||||
|
mount: requireEnv('VAULT_KV_MOUNT'),
|
||||||
|
sharedPath: requireEnv('VAULT_SHARED_PATH'),
|
||||||
|
projectPath: requireEnv('VAULT_PROJECT_PATH'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readVaultPath(config: VaultConfig, path: string) {
|
||||||
|
const response = await fetch(
|
||||||
|
`${config.address}/v1/${config.mount}/data/${path}`,
|
||||||
|
{ headers: { 'X-Vault-Token': config.token } },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Vault read failed for ${path}: ${response.status}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
data?: { data?: Record<string, unknown> };
|
||||||
|
};
|
||||||
|
const data = payload.data?.data;
|
||||||
|
if (!data) {
|
||||||
|
throw new Error(`Vault path ${path} has no KV v2 data.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyEnvironment(values: Record<string, unknown>) {
|
||||||
|
for (const [key, value] of Object.entries(values)) {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new Error(`Vault value ${key} must be a string.`);
|
||||||
|
}
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadVaultEnvironment() {
|
||||||
|
if (process.env.VAULT_ENABLED !== 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = vaultConfig();
|
||||||
|
applyEnvironment(await readVaultPath(config, config.sharedPath));
|
||||||
|
applyEnvironment(await readVaultPath(config, config.projectPath));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user