Initial commit after re-install

This commit is contained in:
2026-02-25 19:11:30 +09:00
commit 7d29779a1f
37 changed files with 6505 additions and 0 deletions

1738
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
server/package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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)
})
}

View 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

View 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
View 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"]
}