あうあうエージェント

https://github.com/omiya0555/auau-agent

Swift

AWS

Python

幼児にオーバースペックなAIエージェント与えてみた!やばしゅぎ!

どりー_ハックツ

8aet4iae8cs

ずみずみ

推しアイデア

幼児の音声を分析・翻訳! オーバースペックなAIエージェント!

作った背景

子供たちの明るい未来のために!!! (というのは建前で) AWS Strands Agents を使ってふざけたかった

推し技術

- AWS Strands Agents - Amazon S3 Vector - Tavily Search - A2A - charm crush - SFSpeechRecognizer - streaming response

プロジェクト詳細

サービス概要

幼児の言語を入力とし、マルチエージェントに構成された検索機能を用いて幼児の真の要求を読み取るアプリケーション

完成品

デモをYouTubeにあげてます!

処理フロー

幼児音声入力(テキスト化) ↓ 検索(RAG,WEB) ↓ 手順出力(モバイル)

処理例

まんまアッチッチ、、っママ (音声入力) ↓ 意味を検索、推測

✨【幼児の要求】 「ママが熱くて心配している、ママを助けたい」 ✨【最終的なゴール】 「医療知識と応急処置技術を完全にマスターし、  家族の健康を守れる家庭医レベルの専門家になる」 ✨ 【マイルストーン】  1.体温と熱の症状を正確に判断する診断力を身につける。  2.氷嚢・解熱剤・水分補給のタイミングを最適化する実践的スキルを身につける。  3.栄養・運動・睡眠を組み合わせて病気を未然に防ぐ予防医学を実践  4.緊急時の救命処置と医療機関との連携システムを完成させる段階的に医療技術を極める

マルチエージェント構成

入力解析エージェント

  • RAG検索(幼児用語PDF)

検索マルチエージェント

  • WEB検索
  • RAG検索(幼児言語発達PDF)

推し技術

image

web検索エージェント編

  • AIエージェントのためのweb検索サービス"tavily"を用いてwebから情報を取得し,まとめるエージェント
agent = Agent( tools=[web_search], description="web検索エージェント", system_prompt=load_system_prompt(), ) server = A2AServer( agent=agent, port=9000, ) server.serve()

S3 Vector RAG検索Agent編

  • AWS S3 バケットがVectorデータの格納に対応
  • スクリプトを用いて、事前に幼児の言語発達に関する論文等のドキュメントを格納
  • AgentにIndex検索Toolを定義
@tool def search_toddler_index(prompt: str, top_k: int = 3) -> str: """ Convert natural language prompt to an embedding (Titan) and query S3 Vector index for similar content. Returns a newline-delimited summary of results. """ try: embed_resp = bedrock.invoke_model( modelId=EMBED_MODEL_ID, body=json.dumps({"inputText": prompt}), ) model_payload = json.loads(embed_resp["body"].read()) embedding = model_payload["embedding"] query_resp = s3vectors.query_vectors( vectorBucketName=VECTOR_BUCKET, indexName=VECTOR_INDEX, queryVector={"float32": embedding}, topK=top_k, returnDistance=True, returnMetadata=True, ) vectors = query_resp.get("vectors", []) if not vectors: return "No similar content found." lines = [] for v in vectors: vid = v.get("id") dist = v.get("distance") meta = v.get("metadata", {}) try: dist_fmt = f"{dist:.4f}" except Exception: dist_fmt = str(dist) lines.append(f"id={vid} distance={dist_fmt} metadata={meta}") return "\n".join(lines) except Exception as e: logger.exception("Vector search failed") return f"Vector search error: {type(e).__name__}: {e}"

A2A プロトコル編

  • Strands AgentsがA2Aに対応
  • オーケストレーターとなるAgentが、幼児言語の解読のために専門Agentを使用
  • RAG検索、WEB検索を併用して高度(過剰)な推測を実現

Swift編

音声認識・文字起こし

Swift標準搭載の Speech FramworkとAVFoundationを用いて実現

import Foundation import Speech import AVFoundation class SpeechRecognizer: ObservableObject { @Published var recognizedText = "" //リアルタイムの認識テキスト @Published var finalizedText = "" //確定したテキスト @Published var isRecording = false //録音中かどうか private var isStopping = false //停止中かどうかを管理 private var audioEngine = AVAudioEngine() private var speechRecognizer: SFSpeechRecognizer? private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? private var recognitionTask: SFSpeechRecognitionTask? private var silenceTimer: Timer? private let silenceDuration: TimeInterval = 0.5 //無音とみなす時間(秒) private var selectedLanguage: String = "ja-JP" //現在選択されている言語を保持 init(language: String = "ja-JP") { self.speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: language)) self.selectedLanguage = language requestAuthorization() } //音声認識の許可をリクエスト private func requestAuthorization() { SFSpeechRecognizer.requestAuthorization { authStatus in DispatchQueue.main.async { switch authStatus { case .authorized: print("音声認識が許可されました") case .denied: print("音声認識が拒否されました") case .restricted: print("音声認識が制限されています") case .notDetermined: print("音声認識がまだ認証されていません") @unknown default: fatalError("未知の認証ステータス") } } } } //録音を開始(言語を引数として指定可能) func startRecording(language: String) { if isRecording || isStopping { return } self.selectedLanguage = language self.speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: language)) recognitionTask?.cancel() recognitionTask = nil let audioSession = AVAudioSession.sharedInstance() do { try audioSession.setCategory(.record, mode: .measurement, options: .duckOthers) try audioSession.setActive(true, options: .notifyOthersOnDeactivation) } catch { print("オーディオセッションの設定エラー: \(error.localizedDescription)") } recognitionRequest = SFSpeechAudioBufferRecognitionRequest() guard let recognitionRequest = recognitionRequest else { fatalError("リクエストの作成に失敗しました") } recognitionRequest.shouldReportPartialResults = true let inputNode = audioEngine.inputNode let recordingFormat = inputNode.outputFormat(forBus: 0) inputNode.removeTap(onBus: 0) inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { (buffer, when) in self.recognitionRequest?.append(buffer) } audioEngine.prepare() do { try audioEngine.start() } catch { print("オーディオエンジンの開始エラー: \(error.localizedDescription)") } isRecording = true recognitionTask = speechRecognizer?.recognitionTask(with: recognitionRequest) { result, error in if let result = result { self.resetSilenceTimer() if result.isFinal { self.finalizedText += result.bestTranscription.formattedString + " " self.recognizedText = "" } else { self.recognizedText = result.bestTranscription.formattedString } } if error != nil { self.stopRecording() } } } //録音を停止 func stopRecording() { if !isRecording || isStopping { return } isStopping = true audioEngine.stop() recognitionRequest?.endAudio() isRecording = false isStopping = false //停止が完了したのでフラグをリセット print("ユーザーによって録音が停止されました") } //無音タイマーをリセット private func resetSilenceTimer() { silenceTimer?.invalidate() silenceTimer = Timer.scheduledTimer(withTimeInterval: silenceDuration, repeats: false) { _ in self.handleSilence() } } //無音が検知されたときの処理 private func handleSilence() { if isRecording && !isStopping { print("無音が検知されました") //現在のタスクをキャンセル recognitionTask?.cancel() recognitionTask = nil recognitionRequest?.endAudio() recognitionRequest = nil audioEngine.stop() audioEngine.inputNode.removeTap(onBus: 0) DispatchQueue.main.async { if !self.recognizedText.isEmpty { self.finalizedText += self.recognizedText + " " self.recognizedText = "" print("ここで確定したよ!") } //停止状態を監視 self.monitorStopState { print("この後に録音再開するよ") self.startRecording(language: self.selectedLanguage) } } } } //停止状態を監視して、停止が完了したら再開 private func monitorStopState(completion: @escaping () -> Void) { Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in if !self.isStopping { //停止が完了したかチェック timer.invalidate() //タイマーを停止 completion() //再開処理を実行 } } } }

Streaming API 通信・出力

ChatGPTのようにリアルタイムでテキストを少しずつ出力できるように実装

↓streaming response の parser

// ストリーミングレスポンスをパース private func parseStreamingResponse(_ asyncBytes: URLSession.AsyncBytes, continuation: AsyncThrowingStream<String, Error>.Continuation) async throws { var buffer = "" for try await line in asyncBytes.lines { if line.isEmpty { continue } // バッファにデータを追加 let jsonStrings = "\(buffer)\(line)" .trimmingCharacters(in: .whitespacesAndNewlines) .components(separatedBy: "data:") .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } buffer = "" // JSON文字列を処理 for (_, jsonString) in jsonStrings.enumerated() { print(jsonString) if jsonString.isEmpty { continue } if jsonString == "event: end" { continuation.finish() return } guard let jsonData = jsonString.data(using: .utf8) else { continue } do { //JSONパースルート let chunk = try JSONDecoder().decode(CompletionChunkResponse.self, from: jsonData) continuation.yield(chunk.choices[0].delta?.content ?? "") } catch { // JSONでなければ生テキストとして返す continuation.yield(jsonString) } } } continuation.finish() }

UI部分

// メッセージ ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 8) { ForEach(viewModel.messages) { message in MessageBubble(message: message) .id(message.id) } // Show streaming message if active if viewModel.isStreaming { MessageBubble(message: ChatMessage(content: viewModel.currentResponse, role: .assistant)) .id("streamingMessage") } } .padding() } .frame(maxWidth: .infinity, maxHeight: .infinity) .onChange(of: viewModel.messages.count) { // 最新メッセージのIDにスクロール if let lastMessage = viewModel.messages.last { withAnimation { proxy.scrollTo(lastMessage.id, anchor: .bottom) } } } .onChange(of: viewModel.currentResponse) { // ストリーミング中のメッセージにスクロール if viewModel.isStreaming { withAnimation { proxy.scrollTo("streamingMessage", anchor: .bottom) } } } if viewModel.isStreaming && viewModel.isLoading { ProgressView("かんがえちゅう") .padding() } }

どりー_ハックツ

@Friedrich_buryu