Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,5 @@ node_modules
!.env.example
*.pem

.gitignore
.gitignore
.pnpm-store/*
66 changes: 65 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,68 @@ hitode/
│ └── architecture.dio.svg # システム構成図
└── README.md # このファイル
```
```

## 🚀 クイックスタート

### 1. リポジトリのクローン

```bash
git clone <repository-url>
cd bedrock-kb-chatbot
```

VSCode から [bedrock-kb-chatbot.code-workspace](./bedrock-kb-chatbot.code-workspace) ファイルを開いて、マルチプロジェクト構成でワークスペースを開きます。

### 2. インフラストラクチャのセットアップ

```bash
cd infra
pnpm install
pnpm build
pnpm cdk deploy
```

詳細な手順は [infra/README.md](./infra/README.md) を参照してください。

### 3. アプリケーションの起動

```bash
cd web
pnpm install
pnpm db:migrate # データベースのセットアップ
pnpm dev # 開発サーバーの起動
```

ブラウザで [http://localhost:3000](http://localhost:3000) を開いてアプリケーションを確認します。

詳細な手順は [web/README.md](./web/README.md) を参照してください。

## 📝 開発コマンド

### Web アプリケーション (web/)

```bash
pnpm dev # 開発サーバーの起動(Turbopack)
pnpm build # プロダクションビルド
pnpm start # プロダクションサーバーの起動
pnpm lint # Biome によるコード検査
pnpm format # Biome によるコードフォーマット
pnpm db:migrate # データベースマイグレーション
pnpm db:studio # Drizzle Studio(データベース GUI)
```

### インフラストラクチャ (infra/)

```bash
pnpm build # TypeScript のビルド
pnpm watch # ファイル変更の監視とビルド
pnpm test # Jest によるテスト実行
pnpm cdk synth # CloudFormation テンプレートの生成
pnpm cdk deploy # AWS へのデプロイ
pnpm cdk diff # 変更内容の確認
pnpm lint # Biome によるコード検査
pnpm format # Biome によるコードフォーマット
```

c
43 changes: 9 additions & 34 deletions infra/bin/infra.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,57 +3,32 @@ import * as cdk from "aws-cdk-lib";
import * as dotenv from "dotenv";
import { getConfig } from "../lib/config/environmental_config";
import { AmazonBedrockKbStack } from "../lib/stack/bedrock-kb-stack";
import { SecretsStack } from "../lib/stack/secrets-stack";
import { SageMakerStack } from "../lib/stack/sagemaker-stack"; // 1. 追加したスタックをインポート

dotenv.config();

const app = new cdk.App();

// 環境名を取得
const stage = app.node.tryGetContext("stage") || "test";
const stagePrefix = stage.charAt(0).toUpperCase() + stage.slice(1);

// 環境設定を取得(エントリーポイントでのみ呼び出す)
const config = getConfig(stage);

const env = {
account: process.env.CDK_DEFAULT_ACCOUNT ?? process.env.AWS_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION ?? process.env.AWS_REGION,
};

// Secrets スタック(認証情報を管理
const secretsStack = new SecretsStack(app, `SecretsStack${stagePrefix}`, {
// 2. Bedrock Stack を先に定義(S3バケット等の情報を後続に渡すため
const bedrockKbStack = new AmazonBedrockKbStack(app, `BedrockKbStack${stagePrefix}`, {
stage,
confluence: config.bedrockKb?.confluence
? {
confluenceAppKey: config.bedrockKb.confluence.confluenceAppKey,
confluenceAppSecret: config.bedrockKb.confluence.confluenceAppSecret,
confluenceAccessToken:
config.bedrockKb.confluence.confluenceAccessToken,
confluenceRefreshToken:
config.bedrockKb.confluence.confluenceRefreshToken,
}
: undefined,
env,
});

// Bedrock Knowledge Base スタック
const bedrockKbConfig = config.bedrockKb;
if (!bedrockKbConfig) {
throw new Error(
`Bedrock KB configuration is not defined for environment: ${stage}`,
);
}

new AmazonBedrockKbStack(app, `BedrockKbStack${stagePrefix}`, {
// 3. SageMaker Stack を定義
// 必要に応じて、bedrockKbStack で作成したバケットの参照などを props で渡します
new SageMakerStack(app, `SageMakerStack${stagePrefix}`, {
stage,
confluence:
bedrockKbConfig.confluence && secretsStack.confluenceSecretArn
? {
secretArn: secretsStack.confluenceSecretArn,
hostUrl: bedrockKbConfig.confluence.hostUrl,
spaces: bedrockKbConfig.confluence.spaces,
}
: undefined,
env,
});
// 作業に必要であれば、bedrockKbStackからバケット情報を渡す設計にします
// dataSourceBucket: bedrockKbStack.dataSourceBucket
});
19 changes: 19 additions & 0 deletions infra/lambda/copy/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { S3Client, CopyObjectCommand } from "@aws-sdk/client-s3";

const s3 = new S3Client({});

export const handler = async (event: any) => {
const record = event.Records[0];
const sourceBucket = record.s3.bucket.name;
const key = record.s3.object.key;

await s3.send(
new CopyObjectCommand({
Bucket: process.env.TARGET_BUCKET!,
CopySource: `${sourceBucket}/${key}`,
Key: key,
})
);

console.log("Copied:", key);
};
17 changes: 17 additions & 0 deletions infra/lambda/sync-kb/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {
BedrockAgentClient,
StartIngestionJobCommand,
} from "@aws-sdk/client-bedrock-agent";

const client = new BedrockAgentClient({});

export const handler = async () => {
await client.send(
new StartIngestionJobCommand({
knowledgeBaseId: process.env.KNOWLEDGE_BASE_ID!,
dataSourceId: process.env.DATA_SOURCE_ID!,
})
);

console.log("Ingestion started");
};
2 changes: 1 addition & 1 deletion infra/lib/config/environmental_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ const environmentConfigs: { [key: string]: EnvironmentConfig } = {
},
bedrockKb: {
embeddingModelArn:
"arn:aws:bedrock:ap-northeast-1::foundation-model/amazon.titan-embed-text-v1",
"arn:aws:bedrock:ap-northeast-1::foundation-model/amazon.titan-embed-text-v2",
aurora: {
instanceType: "t3.medium",
version: "16.4",
Expand Down
162 changes: 162 additions & 0 deletions infra/lib/lambda/image-ocr/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import {
BedrockRuntimeClient,
InvokeModelCommand,
} from "@aws-sdk/client-bedrock-runtime";
import {
GetObjectCommand,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import type { S3Event } from "aws-lambda";

const bedrock = new BedrockRuntimeClient();
const s3 = new S3Client();

const RAW_BUCKET = process.env.RAW_BUCKET!;
const DATA_SOURCE_BUCKET = process.env.DATA_SOURCE_BUCKET!;
const VLM_MODEL_ID = process.env.VLM_MODEL_ID!;

const SUPPORTED_EXTENSIONS = new Set([
".jpg",
".jpeg",
".png",
".gif",
".webp",
]);

export const handler = async (event: S3Event) => {
for (const record of event.Records) {
const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, " "));

// key format: raw-images/{imageId}/{filename}
const parts = key.split("/");
if (parts.length < 3) {
console.log(`Unexpected key format, skipping: ${key}`);
continue;
}

const imageId = parts[1];
const filename = parts[2];
const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase();

if (!SUPPORTED_EXTENSIONS.has(ext)) {
console.log(`Unsupported extension, skipping: ${key}`);
continue;
}

console.log(`Processing: ${imageId}/${filename}`);

try {
// 1. S3 から画像バイナリを取得
const imageObj = await s3.send(
new GetObjectCommand({ Bucket: RAW_BUCKET, Key: key }),
);
const imageBytes = await imageObj.Body!.transformToByteArray();
const base64Image = Buffer.from(imageBytes).toString("base64");
const mediaType = resolveMediaType(ext);

// 2. Claude Vision で OCR + 説明文生成
const analysis = await analyzeImage(base64Image, mediaType);
console.log(
`Analysis done — ocr: "${analysis.ocrText.slice(0, 60)}", desc: "${analysis.description.slice(0, 60)}"`,
);

// 3. テキストを dataSourceBucket に保存(Bedrock KB の ingestion 対象)
const textContent = [
`# Image: ${filename}`,
"",
"## OCR Text",
analysis.ocrText || "(no text found)",
"",
"## Description",
analysis.description,
"",
`## Metadata`,
`- imageId: ${imageId}`,
`- filename: ${filename}`,
`- s3Key: ${key}`,
`- processedAt: ${new Date().toISOString()}`,
].join("\n");

await s3.send(
new PutObjectCommand({
Bucket: DATA_SOURCE_BUCKET,
Key: `images/${imageId}.txt`,
Body: textContent,
ContentType: "text/plain; charset=utf-8",
}),
);

console.log(`Done: ${imageId}/${filename} -> images/${imageId}.txt`);
} catch (err) {
console.error(`Failed to process ${imageId}/${filename}:`, err);
}
}
};

async function analyzeImage(
base64Image: string,
mediaType: string,
): Promise<{ ocrText: string; description: string }> {
const response = await bedrock.send(
new InvokeModelCommand({
modelId: VLM_MODEL_ID,
contentType: "application/json",
accept: "application/json",
body: JSON.stringify({
anthropic_version: "bedrock-2023-05-31",
max_tokens: 2048,
messages: [
{
role: "user",
content: [
{
type: "image",
source: {
type: "base64",
media_type: mediaType,
data: base64Image,
},
},
{
type: "text",
text: `この画像を分析してください。以下の2つの情報をJSON形式で返してください。

1. "ocrText": 画像内に表示されているテキストをすべて抽出(テキストがない場合は空文字列)
2. "description": 画像の内容を詳しく説明(写っているもの、場所、テーマ、色調、雰囲気など)

JSONのみを返してください(コードブロック不要):
{"ocrText": "...", "description": "..."}`,
},
],
},
],
}),
}),
);

const body = JSON.parse(new TextDecoder().decode(response.body));
const text: string = body.content?.[0]?.text ?? "{}";

try {
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
return {
ocrText: String(parsed.ocrText ?? ""),
description: String(parsed.description ?? ""),
};
}
} catch {
console.error("Failed to parse VLM response:", text.slice(0, 200));
}

return { ocrText: "", description: text };
}

function resolveMediaType(ext: string): string {
if (ext === ".png") return "image/png";
if (ext === ".gif") return "image/gif";
if (ext === ".webp") return "image/webp";
return "image/jpeg";
}
Loading
Loading