Initial commit after re-install
This commit is contained in:
1738
server/package-lock.json
generated
Normal file
1738
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
server/package.json
Normal file
32
server/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"migrate": "tsx src/migrate.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
"pg": "^8.17.2",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
18
server/run.sh
Executable file
18
server/run.sh
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd /home/dsyoon/workspace/meeting_ai/server
|
||||
|
||||
PORT="${PORT:-8018}"
|
||||
|
||||
if lsof -ti tcp:"${PORT}" >/dev/null 2>&1; then
|
||||
echo "Stopping existing server on port ${PORT}..."
|
||||
lsof -ti tcp:"${PORT}" | xargs -r kill -9
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
PORT="${PORT}" nohup npm run start > server.log 2>&1 &
|
||||
echo "Server started (PID: $!). Logs: server.log"
|
||||
21
server/src/db.ts
Normal file
21
server/src/db.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import dotenv from 'dotenv'
|
||||
import pg from 'pg'
|
||||
import path from 'path'
|
||||
|
||||
dotenv.config()
|
||||
dotenv.config({ path: path.resolve(process.cwd(), '../.env') })
|
||||
|
||||
const { Pool } = pg
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST || 'ncue.net',
|
||||
port: Number(process.env.DB_PORT || 5432),
|
||||
database: process.env.DB_NAME || 'meeting_ai',
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
})
|
||||
|
||||
export const db = {
|
||||
query: (text: string, params?: unknown[]) => pool.query(text, params),
|
||||
connect: () => pool.connect(),
|
||||
}
|
||||
51
server/src/index.ts
Normal file
51
server/src/index.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import http from 'http'
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import { WebSocketServer } from 'ws'
|
||||
import meetingsRouter from './routes/meetings.js'
|
||||
import answerSuggestionsRouter from './routes/answerSuggestions.js'
|
||||
import { runMigrations } from './migrate.js'
|
||||
|
||||
const app = express()
|
||||
app.use(cors())
|
||||
app.use(express.json({ limit: '2mb' }))
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.use('/api/meetings', meetingsRouter)
|
||||
app.use('/api/answer_suggestions', answerSuggestionsRouter)
|
||||
|
||||
const server = http.createServer(app)
|
||||
const wss = new WebSocketServer({ server })
|
||||
|
||||
function broadcast(data: unknown) {
|
||||
const payload = JSON.stringify(data)
|
||||
for (const client of wss.clients) {
|
||||
if (client.readyState === client.OPEN) {
|
||||
client.send(payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.locals.broadcast = broadcast
|
||||
|
||||
wss.on('connection', (socket) => {
|
||||
socket.send(JSON.stringify({ type: 'connected' }))
|
||||
})
|
||||
|
||||
const port = Number(process.env.PORT || 4000)
|
||||
|
||||
runMigrations()
|
||||
.then(() => {
|
||||
server.listen(port, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Server listening on ${port}`)
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Migration failed:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
102
server/src/llm.ts
Normal file
102
server/src/llm.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
type SuggestionInput = {
|
||||
context: string[]
|
||||
question: string
|
||||
}
|
||||
|
||||
function normalize(text: string) {
|
||||
return text.replace(/\s+/g, ' ').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function uniqueSuggestions(items: string[]) {
|
||||
const seen = new Set<string>()
|
||||
const result: string[] = []
|
||||
for (const item of items) {
|
||||
const key = normalize(item)
|
||||
if (!key || seen.has(key)) continue
|
||||
seen.add(key)
|
||||
result.push(item.trim())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function truncateQuestion(question: string) {
|
||||
const trimmed = question.trim()
|
||||
if (!trimmed) return '질문'
|
||||
const firstSentence = trimmed.split(/[?!.]/)[0] || trimmed
|
||||
if (firstSentence.length <= 40) return firstSentence
|
||||
return `${firstSentence.slice(0, 40)}…`
|
||||
}
|
||||
|
||||
function buildRuleBasedSuggestions(question: string): string[] {
|
||||
const base = truncateQuestion(question)
|
||||
return [
|
||||
`${base}에 대해 현재 상황을 먼저 요약드리겠습니다.`,
|
||||
`${base}의 핵심 포인트는 일정과 비용입니다.`,
|
||||
`${base}에 대해서는 리스크와 대안을 비교해보겠습니다.`,
|
||||
`${base} 관련해서 우선순위를 정해 제안드릴게요.`,
|
||||
`${base}에 대한 다음 액션을 정리해드리겠습니다.`,
|
||||
]
|
||||
}
|
||||
|
||||
async function buildOpenAiSuggestions(input: SuggestionInput): Promise<string[]> {
|
||||
const apiKey = process.env.OPENAI_API_KEY
|
||||
if (!apiKey) {
|
||||
return buildRuleBasedSuggestions(input.question)
|
||||
}
|
||||
|
||||
const prompt = [
|
||||
'당신은 회의 중 답변 후보를 제안하는 비서입니다.',
|
||||
'질문에 대해 5개의 간결한 한국어 답변 후보만 리스트로 만들어 주세요.',
|
||||
'각 답변은 한 문장으로 짧게 작성합니다.',
|
||||
'',
|
||||
`질문: ${input.question}`,
|
||||
'',
|
||||
'최근 대화 요약:',
|
||||
...input.context.map((line) => `- ${line}`),
|
||||
].join('\n')
|
||||
|
||||
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
temperature: 0.4,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return buildRuleBasedSuggestions(input.question)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
choices?: { message?: { content?: string } }[]
|
||||
}
|
||||
const content = data.choices?.[0]?.message?.content || ''
|
||||
const lines = content
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/^\s*[\-\d\.\)]\s*/, '').trim())
|
||||
.filter(Boolean)
|
||||
const suggestions = uniqueSuggestions(lines).slice(0, 5)
|
||||
if (suggestions.length >= 5) {
|
||||
return suggestions
|
||||
}
|
||||
const fallback = buildRuleBasedSuggestions(input.question)
|
||||
return uniqueSuggestions([...suggestions, ...fallback]).slice(0, 5)
|
||||
}
|
||||
|
||||
export async function generateSuggestions(input: SuggestionInput): Promise<string[]> {
|
||||
if (!input.question.trim()) {
|
||||
return uniqueSuggestions(buildRuleBasedSuggestions(input.question)).slice(0, 5)
|
||||
}
|
||||
const suggestions = await buildOpenAiSuggestions(input)
|
||||
return uniqueSuggestions(suggestions).slice(0, 5)
|
||||
}
|
||||
49
server/src/migrate.ts
Normal file
49
server/src/migrate.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { db } from './db.js'
|
||||
|
||||
const ddlStatements = [
|
||||
`CREATE TABLE IF NOT EXISTS meetings (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
started_at TIMESTAMPTZ NOT NULL,
|
||||
ended_at TIMESTAMPTZ,
|
||||
title TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS utterances (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
meeting_id BIGINT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE,
|
||||
speaker TEXT NOT NULL DEFAULT 'unknown',
|
||||
text TEXT NOT NULL,
|
||||
ts TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS answers (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
meeting_id BIGINT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE,
|
||||
question_text TEXT NOT NULL,
|
||||
suggestions JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_utterances_meeting_id_ts
|
||||
ON utterances(meeting_id, ts);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_answers_meeting_id_created_at
|
||||
ON answers(meeting_id, created_at);`,
|
||||
]
|
||||
|
||||
export async function runMigrations() {
|
||||
for (const stmt of ddlStatements) {
|
||||
await db.query(stmt)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1]?.includes('migrate')) {
|
||||
runMigrations()
|
||||
.then(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Migrations complete.')
|
||||
process.exit(0)
|
||||
})
|
||||
.catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Migration failed:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
23
server/src/routes/answerSuggestions.ts
Normal file
23
server/src/routes/answerSuggestions.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Router } from 'express'
|
||||
import { generateSuggestions } from '../llm.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
const { context, question } = req.body as { context?: string[]; question?: string }
|
||||
if (!question) {
|
||||
return res.status(400).json({ message: 'question이 필요합니다.' })
|
||||
}
|
||||
|
||||
try {
|
||||
const suggestions = await generateSuggestions({
|
||||
context: Array.isArray(context) ? context : [],
|
||||
question,
|
||||
})
|
||||
return res.json({ suggestions })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ message: '답변 후보를 생성하지 못했습니다.' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
146
server/src/routes/meetings.ts
Normal file
146
server/src/routes/meetings.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Router } from 'express'
|
||||
import { db } from '../db.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', async (_req, res) => {
|
||||
try {
|
||||
const result = await db.query(
|
||||
'SELECT id, started_at, ended_at, title FROM meetings ORDER BY started_at DESC'
|
||||
)
|
||||
res.json(result.rows)
|
||||
} catch (err) {
|
||||
res.status(500).json({ message: '대화 목록을 불러오지 못했습니다.' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:id', async (req, res) => {
|
||||
const meetingId = Number(req.params.id)
|
||||
if (!meetingId) {
|
||||
return res.status(400).json({ message: '잘못된 meeting id입니다.' })
|
||||
}
|
||||
|
||||
try {
|
||||
const meetingResult = await db.query(
|
||||
'SELECT id, started_at, ended_at, title FROM meetings WHERE id = $1',
|
||||
[meetingId]
|
||||
)
|
||||
if (meetingResult.rowCount === 0) {
|
||||
return res.status(404).json({ message: '대화를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
const utterancesResult = await db.query(
|
||||
'SELECT id, meeting_id, speaker, text, ts FROM utterances WHERE meeting_id = $1 ORDER BY ts ASC',
|
||||
[meetingId]
|
||||
)
|
||||
const answersResult = await db.query(
|
||||
'SELECT id, meeting_id, question_text, suggestions, created_at FROM answers WHERE meeting_id = $1 ORDER BY created_at ASC',
|
||||
[meetingId]
|
||||
)
|
||||
|
||||
return res.json({
|
||||
meeting: meetingResult.rows[0],
|
||||
utterances: utterancesResult.rows,
|
||||
answers: answersResult.rows,
|
||||
})
|
||||
} catch (err) {
|
||||
return res.status(500).json({ message: '대화 정보를 불러오지 못했습니다.' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
const { started_at } = req.body as { started_at?: string }
|
||||
if (!started_at) {
|
||||
return res.status(400).json({ message: 'started_at이 필요합니다.' })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await db.query(
|
||||
'INSERT INTO meetings (started_at) VALUES ($1) RETURNING id',
|
||||
[started_at]
|
||||
)
|
||||
return res.json({ id: result.rows[0].id })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ message: '대화를 시작하지 못했습니다.' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:id/end', async (req, res) => {
|
||||
const meetingId = Number(req.params.id)
|
||||
const { ended_at, title } = req.body as { ended_at?: string; title?: string }
|
||||
if (!meetingId || !ended_at) {
|
||||
return res.status(400).json({ message: 'meeting id와 ended_at이 필요합니다.' })
|
||||
}
|
||||
|
||||
try {
|
||||
await db.query('UPDATE meetings SET ended_at = $1, title = $2 WHERE id = $3', [
|
||||
ended_at,
|
||||
title || null,
|
||||
meetingId,
|
||||
])
|
||||
return res.json({ ok: true })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ message: '대화를 종료하지 못했습니다.' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:id/utterances', async (req, res) => {
|
||||
const meetingId = Number(req.params.id)
|
||||
const { text, ts } = req.body as { text?: string; ts?: string }
|
||||
if (!meetingId || !text) {
|
||||
return res.status(400).json({ message: 'meeting id와 text가 필요합니다.' })
|
||||
}
|
||||
|
||||
try {
|
||||
await db.query(
|
||||
'INSERT INTO utterances (meeting_id, text, ts) VALUES ($1, $2, $3)',
|
||||
[meetingId, text, ts || new Date().toISOString()]
|
||||
)
|
||||
return res.json({ ok: true })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ message: '발화를 저장하지 못했습니다.' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:id/answers', async (req, res) => {
|
||||
const meetingId = Number(req.params.id)
|
||||
const { question_text, suggestions } = req.body as {
|
||||
question_text?: string
|
||||
suggestions?: string[]
|
||||
}
|
||||
if (!meetingId || !question_text || !Array.isArray(suggestions)) {
|
||||
return res.status(400).json({ message: '질문과 답변 후보가 필요합니다.' })
|
||||
}
|
||||
|
||||
try {
|
||||
await db.query(
|
||||
'INSERT INTO answers (meeting_id, question_text, suggestions) VALUES ($1, $2, $3)',
|
||||
[meetingId, question_text, JSON.stringify(suggestions)]
|
||||
)
|
||||
return res.json({ ok: true })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ message: '답변 후보를 저장하지 못했습니다.' })
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/', async (req, res) => {
|
||||
const { ids } = req.body as { ids?: number[] }
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return res.status(400).json({ message: '삭제할 id 목록이 필요합니다.' })
|
||||
}
|
||||
|
||||
const client = await db.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
await client.query('DELETE FROM meetings WHERE id = ANY($1)', [ids])
|
||||
await client.query('COMMIT')
|
||||
return res.json({ ok: true })
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK')
|
||||
return res.status(500).json({ message: '대화를 삭제하지 못했습니다.' })
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
15
server/tsconfig.json
Normal file
15
server/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user