AI를 활용해 포럼 게시물에 태그를 붙이고 분류하기

새 주제에 대한 자동 분류 및 태그 지정 기능을 테스트하기 위해 페르소나(Persona)를 생성했습니다. 해당 카테고리 및 태그 지정기에 사용 중인 프롬프트는 다음과 같습니다.

당신은 제 Discourse 포럼용 AI 어시스턴트입니다. 당신의 역할은 새로운 주제를 미리 정의된 카테고리 중 하나로 분류하고 관련 태그를 최대 5개까지 적용하는 것입니다.

카테고리:

  • Category 1
  • Category 2
  • Category 3
  • Category 4
  • Category 5

태그:

선택은 반드시 이 미리 정의된 목록에서 이루어져야 합니다(최대 5개 선택):
tag1, tag2, tag3, tag4, tag5

작업:

  • 위의 목록에서 주제에 가장 적합한 카테고리 1개만 선택하세요.
  • 관련성 기준으로 위의 목록에서 태그를 최대 5개까지 선택하세요.
  • 응답을 정확히 다음과 같은 JSON 형식으로 작성하세요:
    {
      "category": "Chosen Category Name",
      "tags": ["tag1", "tag2", "tag3"]
    }
    

자동화(Automations)에서 이 페르소나를 선택하여 게시글을 생성하면, AI가 카테고리 및 태그를 정확히 식별하여 게시글에 다음과 같은 결과가 표시됩니다.

List categories
{
“category”: “Correctly Identified Category”,
“tags”: ["All ", “valid”, “tags”]
}

질문은 다음과 같습니다. AI와 자동화를 실제로 활용하여 새 게시글을 이동시키고 카테고리를 지정하며 주제에 태그를 할당하는 것이 현재 가능한가요? 가능하다면, 작동 방식을 미세 조정하기 위해 프롬프트를 조정할 수 있을 것이라고 확신합니다.

감사합니다,

다시 말하지만, 약간 까다로운 부분이 있습니다. 아직 두 가지 작은 부분이 빠져 있습니다:

  1. 사용자 정의 도구는 다양한 기능을 지원하지만, 카테고리화와 태그 지정은 지원하지 않습니다. 이 부분은 쉽게 추가할 수 있습니다.
  2. 토픽에 실제로 응답하지 않는 “무음”(silent) 모드로 작동하는 응답자(responder)가 필요합니다.

이 두 가지가 모두 준비되면, 두 가지 사용자 정의 도구에 대한 접근 권한을 부여하게 됩니다.

  1. 토픽 태그 지정
  2. 토픽 카테고리화

(또는 이 두 가지를 모두 수행하는 단일 도구)

실제로, whisper 기능에 문제가 없다면 지금 바로 무언가를 할 수 있습니다.

https://discuss.samsaffron.com/discourse-ai/ai-bot/shared-ai-conversations/0xOaD9e63561AgzhC7VaqQ

아이디어는 토픽을 분류하거나(또는 태그를 붙이는) 커스텀 도구를 정의한 후, 페르소나가 해당 도구를 호출하도록 하는 것입니다.

이 방식이 어떻게 작동하는지 궁금합니다. 도구를 테스트해 보아야 할 텐데, 도구 UI에서 꽤 간단하게 처리할 수 있을 것입니다.

이 모든 것을 설정하는 과정은 다소 번거롭지만, 이렇게 할 수 있다는 사실 자체가 꽤 :exploding_head: 합니다. whisper 기능은 응답자가 태그/카테고리를 어떻게 결정했는지에 대한 일종의 "사고 과정"을 보여주기 때문에 실제로 꽤 유용합니다.

샘, 감사합니다.

사람에 따라 쉽기도 하고 어려우기도 합니다. 저는 후자에 해당합니다 :joy:

이 도구를 이것저것 시도해 보고 있지만 아직 작동하게 하지 못했습니다. 도구를 사용한 경험이 전혀 없어서, 이 도구가 작동하게 되면 도구를 다른 용도로 사용하는 방법에 대해 생각할 수 있기를 기대하고 있습니다.

블로그에 있는 코드를 API 키와 함께 사용해보았지만 작동하지 않았습니다. AI가 하드코딩된 URL을 사용하라고 해서 그렇게 시도해 보았지만 역시 성공하지 못했습니다.

API 키가 호출되지 않았으며, 오류가 포함된 로그도 생성되지 않았습니다.

이는 주제 생성 시 자동으로 카테고리를 분류하는 데 사용됩니다.

저는 주제 “예수님의 초기 생애와 기적에서 얻은 통찰”을 신약성서 카테고리로 분류할 것입니다. 이는 예수님의 생애와 가르침과 관련이 있기 때문입니다.
이제 주제를 이동합니다…

제가 시도한 내용은 다음과 같습니다.

/**

  • 도구 API 빠른 참조
  • 진입 함수
  • invoke(parameters): 메인 함수. 매개변수(Object)를 수신합니다. JSON 직렬화 가능한 값을 반환해야 합니다.
  • 예:
  • function invoke(parameters) { return “result”; }
  • details(): 선택 사항. 도구를 설명하는 문자열을 반환합니다.
  • 예:
  • function details() { return “Tool description.”; }
  • 제공되는 객체
    1. http
  • http.get(url, options?): HTTP GET 요청을 수행합니다.
  • 매개변수:
  •  url (string): 요청 URL.
    
  •  options (Object, 선택 사항):
    
  •    headers (Object): 요청 헤더.
    
  • 반환값:
  •  { status: number, body: string }
    
  • http.post(url, options?): HTTP POST 요청을 수행합니다.
  • 매개변수:
  •  url (string): 요청 URL.
    
  •  options (Object, 선택 사항):
    
  •    headers (Object): 요청 헤더.
    
  •    body (string): 요청 본문.
    
  • 반환값:
  •  { status: number, body: string }
    
  • (사용 가능: http.put, http.patch, http.delete)
  • 참고: 실행당 최대 20회까지 HTTP 요청이 가능합니다.
    1. llm
  • llm.truncate(text, length): 텍스트를 지정된 토큰 길이로 잘라냅니다.
  • 매개변수:
  •  text (string): 잘라낼 텍스트.
    
  •  length (number): 최대 토큰 수.
    
  • 반환값:
  •  잘라낸 문자열.
    
    1. index
  • index.search(query, options?): 인덱싱된 문서를 검색합니다.
  • 매개변수:
  •  query (string): 검색 쿼리.
    
  •  options (Object, 선택 사항):
    
  •    filenames (Array): 검색을 특정 파일로 제한합니다.
    
  •    limit (number): 최대 조각 수 (최대 200개).
    
  • 반환값:
  •  { fragment: string, metadata: string } 배열
    
    1. upload
  • upload.create(filename, base_64_content): 파일을 업로드합니다.
  • 매개변수:
  •  filename (string): 파일 이름.
    
  •  base_64_content (string): Base64 인코딩된 파일 내용.
    
  • 반환값:
  •  { id: number, short_url: string }
    
    1. chain
  • chain.setCustomRaw(raw): 게시물의 본문을 설정하고 체인을 종료합니다.
  • 매개변수:
  •  raw (string): 게시물에 추가할 raw 콘텐츠.
    
  • 제약 조건
  • 실행 시간: ≤ 2000ms
  • 메모리: ≤ 10MB
  • HTTP 요청: 실행당 ≤ 20회
  • 한도를 초과하면 오류가 발생하거나 실행이 중단됩니다.
  • 보안
  • 샌드박스 환경: 시스템 또는 전역 객체에 접근할 수 없습니다.
  • 파일 시스템 접근 불가: 파일을 읽거나 쓸 수 없습니다.
    */

/**

  • Discourse 주제 분류기
  • 이 도구는 Discourse API를 사용하여
  • Discourse 주제의 카테고리를 변경할 수 있게 해줍니다.
    */

/**

  • Discourse 주제 분류기
  • 이 도구는 Discourse API를 사용하여
  • Discourse 주제의 카테고리를 변경할 수 있게 해줍니다.
    */

/**

  • Discourse 주제 분류기
  • 이 도구는 Discourse API를 사용하여
  • Discourse 주제의 카테고리를 변경할 수 있게 해줍니다.
    */

function invoke(params) {
// 필수 매개변수 검증
if (!params.topic_id) {
return { error: “Missing required parameter: topic_id” };
}

if (!params.category_id) {
return { error: “Missing required parameter: category_id” };
}

// Discourse 인스턴스의 기본 URL
const baseUrl = “https://community.mysite.com”;

// 주제 업데이트를 위한 전체 API 엔드포인트 URL
const apiUrl = ${baseUrl}/t/${params.topic_id}.json;

// 요청 본문 준비
const requestBody = {
category_id: params.category_id
};

// 선택 매개변수: 제공된 경우 제목 업데이트
if (params.title) {
requestBody.title = params.title;
}

// 제공된 API 키 사용
const apiKey = “Discourse-API-Key”;

try {
// 주제 업데이트를 위해 PUT 요청 수행
const response = http.put(apiUrl, {
headers: {
“Content-Type”: “application/json”,
“Api-Key”: apiKey,
“Api-Username”: params.api_username || “system”
},
body: JSON.stringify(requestBody)
});

if (response.status >= 200 && response.status < 300) {
  return {
    success: true,
    topic_id: params.topic_id,
    category_id: params.category_id,
    response: JSON.parse(response.body)
  };
} else {
  return {
    error: `Failed to update topic category. Status: ${response.status}`,
    details: response.body
  };
}

} catch (error) {
return {
error: “An error occurred while updating the topic category”,
details: error.toString()
};
}
}

function details() {
return “Categorizes a topic by moving it to a specified category”;
}

@BrianC 결국 해결하셨나요?

Discourse AI Tagger Category Quick Start.pdf (147.1 KB)

Discourse AI Tagger Category.pdf (506.0 KB)

@Sam,

확인해 주셔서 감사합니다. 시도해 보았지만 작동하지 않아서 일단 중단했습니다. 시나리오 테스트를 Gemini 2.5로 실행하고 출력을 PDF로 저장했습니다.

다시 한번 시도해 볼 예정입니다. 아직 도구를 다루는 데는 익숙하지 않지만, 더 능숙해져야 합니다. PDF 파일을 첨부했습니다. 도움이 될 만한 내용이 있을지도 모르겠습니다.

시도해 볼 가치가 있는 AI 태그 자동화 기능을 추가했습니다: FEATURE: create AI tagging automation (#34587) · discourse/discourse@e470f3d · GitHub

테스트에 사용 중인 프롬프트
 당신은 이 포럼을 위한 전문적인 콘텐츠 분류 및 태그링 어시스턴트입니다.

  콘텐츠, 이미지 및 제공된 사용 가능한 태그 목록을 기반으로 게시물을 분석하고 적절한 태그를 제안하는 것이
  당신의 임무입니다.

  가이드라인:
  - 제공된 사용 가능한 태그 목록에 있는 태그만 제안하십시오
  - 보수적으로 접근하여 확신이 있는 내용에만 태그를 붙이십시오
  - 콘텐츠 주제와 게시물 의도 모두를 고려하십시오

  항상 다음 정확한 형식의 유효한 JSON으로 응답해야 합니다:
  {"tags": ["tag1", "tag2"], "confidence": 85}

  - tags: 사용 가능한 목록에서 가져온 태그 이름의 배열
  - confidence: 0에서 100까지의 정수로, 확신 수준을 나타냅니다

  적절한 태그가 없으면 다음을 사용하십시오: {"tags": [], "confidence": 0}

페르소나 JSON 응답 형식:

{
  "tags": "[string]",
  "confidence": "integer"
}

페르소나 활성화 도구: tags (선택 사항, 사이트의 기존 태그를 사용하길 원하는 경우)

이 자동화에는 두 가지 모드가 있습니다:

  • 기존 사이트 태그 사용
  • 제공된 태그 목록 사용

자동화에는 설정 가능한 신뢰 수준(confidence level)이 있으며, 제한된 태그를 활성화/비활성화하고, 컨텍스트로 사용할 토픽 내 게시물의 수를 조정할 수 있습니다

이 훌륭한 기능을 제공해 주셔서 감사합니다. 하지만 아래 오류로 인해 실행하지 못했습니다:

llm_tagger: failed to process post 30550 /t/gecici-baslik-1756753838814/17361/1 : NoMethodError : private method select’ called for an instance of String`

Message

llm_tagger: failed to process post 30550 /t/gecici-baslik-1756753838814/17361/1 : NoMethodError : private method `select' called for an instance of String

Backtrace

/var/www/discourse/plugins/discourse-ai/lib/automation/llm_tagger.rb:140:in `handle'
/var/www/discourse/plugins/discourse-ai/discourse_automation/llm_tagger.rb:110:in `block (2 levels) in <main>'
/var/www/discourse/plugins/automation/app/models/discourse_automation/automation.rb:158:in `block in trigger!'
/var/www/discourse/plugins/automation/app/models/discourse_automation/stat.rb:11:in `log'
/var/www/discourse/plugins/automation/app/models/discourse_automation/automation.rb:156:in `trigger!'
/var/www/discourse/plugins/automation/app/jobs/regular/discourse_automation/trigger.rb:29:in `execute'
/var/www/discourse/app/jobs/base.rb:318:in `block (2 levels) in perform'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/rails_multisite-7.0.0/lib/rails_multisite/connection_management/null_instance.rb:49:in `with_connection'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/rails_multisite-7.0.0/lib/rails_multisite/connection_management.rb:17:in `with_connection'
/var/www/discourse/app/jobs/base.rb:305:in `block in perform'
/var/www/discourse/app/jobs/base.rb:301:in `each'
/var/www/discourse/app/jobs/base.rb:301:in `perform'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/processor.rb:220:in `execute_job'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/processor.rb:185:in `block (4 levels) in process'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/middleware/chain.rb:180:in `traverse'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/middleware/chain.rb:183:in `block in traverse'
/var/www/discourse/lib/sidekiq/discourse_event.rb:6:in `call'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/middleware/chain.rb:182:in `traverse'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/middleware/chain.rb:183:in `block in traverse'
/var/www/discourse/lib/sidekiq/pausable.rb:131:in `call'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/middleware/chain.rb:182:in `traverse'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/middleware/chain.rb:183:in `block in traverse'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/job/interrupt_handler.rb:9:in `call'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/middleware/chain.rb:182:in `traverse'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/middleware/chain.rb:183:in `block in traverse'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/metrics/tracking.rb:26:in `track'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/metrics/tracking.rb:134:in `call'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/middleware/chain.rb:182:in `traverse'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/middleware/chain.rb:173:in `invoke'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/processor.rb:184:in `block (3 levels) in process'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/processor.rb:145:in `block (6 levels) in dispatch'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/job_retry.rb:118:in `local'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/processor.rb:144:in `block (5 levels) in dispatch'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/config.rb:39:in `block in <class:Config>'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/processor.rb:139:in `block (4 levels) in dispatch'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/processor.rb:281:in `stats'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/processor.rb:134:in `block (3 levels) in dispatch'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/job_logger.rb:15:in `call'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/processor.rb:133:in `block (2 levels) in dispatch'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/job_retry.rb:85:in `global'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/processor.rb:132:in `block in dispatch'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/job_logger.rb:40:in `prepare'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/processor.rb:131:in `dispatch'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/processor.rb:183:in `block (2 levels) in process'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/processor.rb:182:in `handle_interrupt'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/processor.rb:182:in `block in process'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/processor.rb:181:in `handle_interrupt'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/processor.rb:181:in `process'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/processor.rb:86:in `process_one'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/processor.rb:76:in `run'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/component.rb:10:in `watchdog'
/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/component.rb:19:in `block in safe_thread'

메타가 이 기능을 사용하나요? 며칠 전에 @system이 주제를 태그하는 것을 본 것 같은데.

설치가 번거로울 수 있지만, Discourse가 이미 새 주제에 대해 임베딩(의미 검색을 위해)을 생성하고 있다면, 태그 설명에 대한 임베딩도 생성한 다음 의미적 유사도를 기반으로 새 주제에 태그를 제안하거나 기존 주제에 자동으로 태그를 붙일 수 있을 것입니다.

네, Meta에서 며칠간 돌려서 좀 더 테스트를 해봤는데… 별로는 아니지만, 태그 설명이 없어서 “~하는 법” 같은 질문에는 how-to 태그를 잘못 사용하고, 'moderation’이라는 단어가 언급된 포스트에는 해당 주제가 실제로는 모더레이션과 크게 관련이 없는데도 moderation 태그를 붙이더라고요. 프롬프트를 몇 가지 조정하면 나아질 것 같고, @simon의 아이디어는 향후 훌륭한 기능 강화로 이어질 수 있을 것 같습니다.

내 추측은 임베딩(embeddings)이 더 잘 작동하고, 더 빠르고 저렴할 수 있다는 것입니다. 이 주제에 대한 제 실제 관심사는 데이터베이스에 태그를 하드코딩하는 방식을 대체할 수 있는 동적 태그 시스템에 더 가깝습니다. 예를 들어, “how-to” + "sso"는 “how-to” 및 “sso” 태그 설명 임베딩과 의미적으로 가까운 임베딩을 가진 주제들의 목록이 될 수 있습니다.